From 88c035c98e2992641d390bd083be400da5d7d3c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:11:38 +0800 Subject: [PATCH 01/88] cleanup(cli): remove the profile-json config entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `./.dsh-tmp-profile/config.json` was the web config-tree boot's user-config plane, but never gained a writer: no production code created or edited it, no test exercised it, and no user documentation named it. The fields it mapped have owners elsewhere — provider/model are the api-gateway's default route and persistenceRoot is an assembly fact, while typed user preferences live in $DSH_HOME/settings.yaml. Delete PROFILE_DIR, PROFILE_FILE, ProfileMapping, PROFILE_MAPPINGS, and readProfile() with the patch source that consumed them. AppCLIEntry now composes patches from CLI flags and the resolved frontend distIndex only; the surrounding layers are unchanged. A file on disk is ignored completely — no migration, replacement format, or deprecation diagnostic, per the pre-release stance. --- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 4 +- ...fig-tree-boot-and-transport-layering.zh.md | 4 +- ...-08-04-remove-profile-json-entry.i18n.yaml | 6 ++ .../2026-08-04-remove-profile-json-entry.md | 32 +++++++++ ...2026-08-04-remove-profile-json-entry.zh.md | 32 +++++++++ apps/cli/config/web.cordis.yml | 6 +- apps/cli/src/app-cli-entry.ts | 70 +++---------------- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 2 +- docs/user/guide/config.zh.md | 2 +- 11 files changed, 94 insertions(+), 72 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index d50428d5ed..aede84e27f 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238 +2026-07-24-web-config-tree-boot-and-transport-layering.md: e4dd8b50fe565deecb6e64d307305c66af50c001 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 54b0a0e499954cd0e2ccd22cffdf7d09bed11a22 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 88f94b1f58..e4dd8b50fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,7 +16,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. +**Config sources have one declaration place each.** yml static values are engineering defaults; CLI flags map onto the `webserver` row; env values enter through yml `!!js` expressions. This decision also introduced a profile json (`./.dsh-tmp-profile/config.json`) as the user-config source, mapped through a static `PROFILE_MAPPINGS` table onto target rows; it never gained a writer and is [now removed](../simplification/2026-08-04-remove-profile-json-entry.md), leaving flags and the assembly fact below as the only patch sources. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. **The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route. @@ -25,7 +25,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals. +- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. IPC carriers remain a recorded deferral; the profile write path and the `$DSH_HOME` profile relocation were dropped with the profile json itself. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index ea2a8f70a6..54b0a0e499 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,7 +16,7 @@ Status: implemented **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 -**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 +**每个配置源有唯一声明位置。** yml 静态值是工程默认;CLI flags 映射到 `webserver` 行;env 值经 yml `!!js` 表达式进入。本决策当时还引入了 profile json(`./.dsh-tmp-profile/config.json`)作为用户配置源,经静态 `PROFILE_MAPPINGS` 表映射到目标行;它始终没有获得写入方,[现已删除](../simplification/2026-08-04-remove-profile-json-entry.md),patch 来源只剩 flags 与下述装配事实。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 **传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。 @@ -25,7 +25,7 @@ Status: implemented ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。 +- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。IPC 载体仍为挂账项;profile 写入路径与 profile 迁 `$DSH_HOME` 已随 profile json 本身一并放弃。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml new file mode 100644 index 0000000000..60bfb506ae --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md +2026-08-04-remove-profile-json-entry.md: 8ca81e2364e095d90c87febfe705ddec14269bf4 +2026-08-04-remove-profile-json-entry.zh.md: bbc3957d11a2051e7c1f9eaaed52d8af38fa1e5b diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md new file mode 100644 index 0000000000..8ca81e2364 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md @@ -0,0 +1,32 @@ +# Agent Note: Removing the profile-json config entry + +Status: implemented + +English | [中文](2026-08-04-remove-profile-json-entry.zh.md) + +## Problem + +`./.dsh-tmp-profile/config.json` was the user-configuration plane of the [web config-tree boot](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md): a read-only JSON object under the invoking directory, mapped by a static `PROFILE_MAPPINGS` table onto three fields across two rows. Its write path and its relocation to the Harness home were recorded there as deferrals, and neither arrived. Nothing in the product ever created or edited the file, no test exercised it, and no user documentation named it — the format existed only as a reader. + +Meanwhile the fields it mapped acquired owners elsewhere. `provider` and `model` are the api-gateway's default route for created and resumed agents, which a session's own picker overrides per agent; `persistenceRoot` is an assembly fact of the shipped composition. Typed user preferences became `$DSH_HOME/settings.yaml` under the [user-settings seam](../architecture/2026-07-28-user-settings-seam.md). What remained was a third user-configuration format, anchored to the invoking directory and behind a hand-maintained mapping table, that nothing wrote. + +## Decision + +`PROFILE_DIR`, `PROFILE_FILE`, `ProfileMapping`, `PROFILE_MAPPINGS`, and `readProfile()` are deleted along with the patch source that consumed them. `AppCLIEntry` composes its patches from CLI flags and the resolved frontend `distIndex` only; the layers around it — shipped base, surface overlay, `--config` or the personal overlay, and `--config-replace` — are unchanged. + +A `.dsh-tmp-profile/config.json` on disk is now ignored completely. There is no migration, no replacement format, and no deprecation diagnostic: the file never had a producer, so there is no installed base to carry forward, and the [pre-release stance](../../../../AGENTS.md) rejects compatibility shims. + +## Alternatives considered + +**Keep the reader until typed settings own `provider`/`model`.** Rejected because the gap is not real: with no writer, the file gave users no way to pin a default route either, so keeping it preserves an unproduced format rather than a capability. + +**Relocate it to `$DSH_HOME`, the deferral the original note recorded.** Rejected because that deferral assumed the write path would arrive with it. Moving a file nothing writes only moves the dead entry, and the Harness home already has an owner for typed user preferences. + +**Report the file through a deprecation diagnostic when it exists.** Rejected because a diagnostic for a format the product never produced would advertise it to users who have never seen it. + +## Consequences + +- Given up: no file-based way to pin `provider`, `model`, or `persistenceRoot` without editing yml or passing `--config`. A persistent default route needs a typed settings namespace owned by whoever creates sessions; `persistenceRoot` stays an assembly fact. +- Bought: one fewer user-configuration format, one less input anchored to the invoking directory, and a patch composition whose only remaining sources are CLI flags and an assembly fact — the fail-loud mapping table goes with it. +- The [web config-tree boot note](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) is only partially superseded: its composition, boot-glue, transport, and export decisions stand. Both notes stay cross-linked, and its profile facts were rewritten in place. +- Absence is verified by repo-wide search: `.dsh-tmp-profile`, `PROFILE_MAPPINGS`, and `readProfile` have no remaining match. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md new file mode 100644 index 0000000000..bbc3957d11 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 删除 profile-json 配置入口 + +Status: implemented + +[English](2026-08-04-remove-profile-json-entry.md) | 中文 + +## Problem + +`./.dsh-tmp-profile/config.json` 曾是 [web 配置树启动](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md)的用户配置面:调用目录下的一个只读 JSON 对象,经静态 `PROFILE_MAPPINGS` 表映射到两个行上的三个字段。它的写路径以及迁往 Harness home 的计划都记在那条 Note 里作为延后项,两者都没有落地。产品中从未有任何代码创建或编辑该文件,没有测试覆盖它,也没有用户文档提到它——这个格式只存在读取方。 + +与此同时,它映射的字段各自有了别处的归属。`provider` 与 `model` 是 api-gateway 为新建和恢复的 agent 提供的默认路由,会话自己的选择器可按 agent 覆盖它;`persistenceRoot` 是交付组合的装配事实。类型化的用户偏好则由 [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) 下的 `$DSH_HOME/settings.yaml` 承接。剩下的只是第三个用户配置格式:锚定在调用目录、藏在一张手工维护的映射表后面,而且没有任何东西写它。 + +## Decision + +`PROFILE_DIR`、`PROFILE_FILE`、`ProfileMapping`、`PROFILE_MAPPINGS` 和 `readProfile()` 连同消费它们的那个 patch 来源一并删除。`AppCLIEntry` 现在只从 CLI 标志和解析出的前端 `distIndex` 合成 patch;它周围的各层——交付基座、surface overlay、`--config` 或个人 overlay、以及 `--config-replace`——保持不变。 + +磁盘上的 `.dsh-tmp-profile/config.json` 现在被完全忽略。没有迁移、没有替代格式、也没有弃用诊断:该文件从来没有生产方,因此不存在需要承接的存量,而[未发布阶段的立场](../../../../AGENTS.md)拒绝兼容垫片。 + +## Alternatives considered + +**保留读取方,直到类型化 settings 接管 `provider`/`model`。** 否决,因为这个缺口并不真实存在:既然没有写入方,该文件同样没有给用户任何钉住默认路由的途径,保留它保住的是一个无人生产的格式,而不是一项能力。 + +**按原 Note 记录的延后项,把它迁到 `$DSH_HOME`。** 否决,因为那条延后项的前提是写路径会随之到来。搬动一个没人写的文件只是搬动了这个死入口,而 Harness home 已经有了类型化用户偏好的归属者。 + +**文件存在时通过弃用诊断报告它。** 否决,因为为一个产品从未生产过的格式给出诊断,等于向从没见过它的用户宣传它。 + +## Consequences + +- 放弃的:不再有基于文件、无需编辑 yml 或传 `--config` 就能钉住 `provider`、`model` 或 `persistenceRoot` 的途径。持久的默认路由需要一个由会话创建方拥有的类型化 settings namespace;`persistenceRoot` 仍是装配事实。 +- 换来的:少一个用户配置格式,少一个锚定在调用目录的输入,以及一处仅剩 CLI 标志与装配事实两个来源的 patch 合成——那张 fail-loud 映射表随之消失。 +- [web 配置树启动 Note](../architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) 只被部分取代:它关于组合、启动胶水、传输与导出的决策仍然成立。两条 Note 保持互链,其中与 profile 相关的事实已就地改写。 +- 缺席由全仓搜索验证:`.dsh-tmp-profile`、`PROFILE_MAPPINGS` 与 `readProfile` 均无残留匹配。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index daf597916e..efd2f93b2a 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -1,6 +1,6 @@ # `dsh web` — the browser surface, as a patch list over `base.cordis.yml`. # The launcher includes the base and applies this file, then any `--config` -# overlay, then AppCLIEntry's profile-json and CLI-flag patches, as sibling patch +# overlay, then AppCLIEntry's CLI-flag patches, as sibling patch # lists at ONE include level: patches never cross an include boundary, so # stacking overlays as nested includes would silently stop reaching base rows. # @@ -81,8 +81,8 @@ name: '@deepseek-ai/dsh-host-directory-picker-auto' # The API gateway: the transport-agnostic dispatch face every client shape - # shares. provider/model are the host default routing — the profile json's - # mapping target (user config overrides these engineering defaults). + # shares. provider/model are the host default route for created and resumed + # agents; a session's own picker overrides it per agent. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' config: diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index eaa1902eff..e46c8d653a 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -2,8 +2,8 @@ * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share * (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly). * Everything here is what must exist before the Loader runs: the patch - * composition over the shipped base and surface overlay (profile json + CLI - * flags + the resolved frontend dist), and the fail-loud activation audit after the tree + * composition over the shipped base and surface overlay (CLI flags + the + * resolved frontend dist), and the fail-loud activation audit after the tree * settles. The environment is what the bin already loaded (ambient plus the * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential * provider and is never hoisted here. @@ -12,7 +12,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' import { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' @@ -26,10 +26,6 @@ import { // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' -/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */ -const PROFILE_DIR = '.dsh-tmp-profile' -const PROFILE_FILE = 'config.json' - /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */ const TELEMETRY_ROW_ID = 'telemetry-otel' @@ -100,25 +96,6 @@ export function configHasTelemetryRow(file: string): boolean { row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID)) } -/** One profile-json key mapped onto a yml row's config field. */ -interface ProfileMapping { - jsonPath: string - entryId: string - configKey: string -} - -/** - * The static profile→row mapping table. json is user config and wins over the - * yml engineering default per field; a json key absent from this table fails - * loud (a typo silently ignored would read as "setting has no effect"). - * Developers extend deployments by adding rows here. - */ -const PROFILE_MAPPINGS: ProfileMapping[] = [ - { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' }, - { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' }, - { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' }, -] - // The include's YAML dialect: `!!js` scalars become expression nodes the // Loader evaluates at entry activation. The bypass parse below must accept // them (and passing one through a patch unchanged is legal). @@ -135,14 +112,14 @@ export interface AppCLIEntryOptions { configPath: string /** * Absolute path of this surface's overlay: a patch list applied over - * {@link configPath} before this entry's own profile/flag patches. Its rows + * {@link configPath} before this entry's own flag patches. Its rows * are also merge inputs, so a flag override preserves the overlay's other * fields on the same row. */ overlayPath: string /** * Optional explicit overlay applied after {@link overlayPath} and before - * this entry's own profile/flag patches. When absent, the personal + * this entry's own flag patches. When absent, the personal * `$DSH_HOME/config.yaml` overlay is applied instead. */ extraOverlayPath?: string @@ -205,8 +182,8 @@ export class AppCLIEntry { } /** - * Compose the patch set from profile json, CLI flags, and the resolved - * frontend dist. Patches replace a row's config wholesale, so each patched row's yml + * Compose the patch set from CLI flags and the resolved frontend dist. + * Patches replace a row's config wholesale, so each patched row's yml * static values are re-read here (bypass parse) and merged under the overrides. */ private composePatches(): void { @@ -218,28 +195,19 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 1: profile json (missing file = empty; unmapped key = loud). - for (const [key, value] of Object.entries(this.readProfile())) { - const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) - if (mapping === undefined) { - throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) - } - put(mapping.entryId, mapping.configKey, value) - } - - // Source 2: CLI flags (field set disjoint from the json mappings). + // Source 1: CLI flags. if (this.options.host !== undefined) put('webserver', 'host', this.options.host) if (this.options.port !== undefined) put('webserver', 'port', this.options.port) if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) - // Source 2b: authorities for the /api browser-trust fence (rationale on + // Source 1b: authorities for the /api browser-trust fence (rationale on // resolveLanTrust). const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) this.lanAddresses = lanAddresses if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) - // Source 3: the frontend dist — an assembly fact of this app, never yml + // Source 2: the frontend dist — an assembly fact of this app, never yml // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) @@ -262,7 +230,7 @@ export class AppCLIEntry { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would // silently stop reaching base rows. The surface overlay applies first, then - // this entry's profile-json and CLI-flag patches, which therefore win. + // this entry's CLI-flag patches, which therefore win. const compose = (overlay: PatchOptions[]): PatchOptions[] => [ ...loadOverlayPatches('dsh', this.options.overlayPath), ...overlay, @@ -327,22 +295,6 @@ export class AppCLIEntry { return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] } - /** Profile json under cwd; read-only — never created here, absent = no user config. */ - private readProfile(): Record { - let raw: string - try { - raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} - throw error - } - const parsed: unknown = JSON.parse(raw) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) - } - return parsed as Record - } - /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */ private resolveDistIndex(): string { const require = createRequire(import.meta.url) diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 4fd91343ac..6d1265e9f3 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: 0e2e0e7e7077adcacfaada1d038a0b1e63fcc0cd -config.zh.md: 850a841286fe77db9169738b0b155f008205a1a8 +config.md: 6f656b573490a08ec893f4d14b487e6082015049 +config.zh.md: d4bb30023df46845ea720f3e6a45184479df0e72 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 0e2e0e7e70..6f656b5734 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -50,7 +50,7 @@ Plugins load in file order. Place plugins that depend on services after the appl ## CLI overlays -The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before Web profile and CLI-flag patches. +The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before the Web launcher's CLI-flag patches. A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 850a841286..d4bb30023d 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -50,7 +50,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 ## CLI 覆盖层 -TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web profile 与命令行标志补丁之前添加覆盖。 +TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web 启动器的命令行标志补丁之前添加覆盖。 补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 From 03b534de1650255f5911eb79f3e44ada2bb37ed5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:50:38 +0800 Subject: [PATCH 02/88] feat(credentials): move the store to .credentials.yaml and layer $DSH_HOME/.env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable secret store it could not be hoisted into process.env — hoisting makes every stored key read as a read-only launch override and blocks rotation from the TUI and the web page. But its name and dotenv format promise an environment file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the same file was silently ignored: only the credential provider read the document, and it addresses credential references alone. Split the two jobs into two files. .credentials.yaml is the provider-managed store: a strict YAML mapping of CredentialRef to non-empty string, no version field, no wrapper level. Because it holds credentials and nothing else, a non-mapping root, a non-identifier key, a non-string value, an empty string, a duplicate key, and malformed YAML are all rejections rather than skipped entries — loud at boot and at a write, warn-and-keep-last-good on a live reload. The dotenv physical-line editor gives way to a patch of the parsed document, so comments and untouched entries keep their formatting and any string value round-trips, multi-line included. Writer lock, read-modify-write, atomic 0600 write under a 0700 directory, watcher, self-write suppression, and quiescent disposal are unchanged. $DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new loadLayeredEnv loads the invoking directory's .env then the Harness home's, giving user < project < inherited; the home resolves from the inherited environment first, so a project .env cannot redirect it. Credential precedence is unchanged: the live environment still wins read-only over the file, and shadowed writes still reject. Whether a provider-managed store should instead win over the environment is a separate decision. No migration: a key already in $DSH_HOME/.env keeps resolving through the new environment layer, as a read-only env source that shadows the stored one. --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...29-request-level-llm-config-credentials.md | 2 +- ...request-level-llm-config-credentials.zh.md | 2 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +- ...tial-boundaries-and-atomic-registration.md | 2 +- ...l-boundaries-and-atomic-registration.zh.md | 2 +- ...-yaml-and-user-environment-layer.i18n.yaml | 6 + ...entials-yaml-and-user-environment-layer.md | 50 ++++ ...ials-yaml-and-user-environment-layer.zh.md | 50 ++++ THIRD_PARTY_NOTICES.md | 1 - apps/cli/config/base.cordis.yml | 9 +- apps/cli/src/app-cli-entry.ts | 6 +- apps/cli/src/bin.ts | 4 +- apps/cli/src/tui.ts | 11 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 31 +-- apps/web/tests/models-settings.e2e.ts | 10 +- .../tests/onboarding-deepseek-config.e2e.ts | 4 +- docs/config-catalog.md | 4 +- examples/headless-agent/cordis.yml | 2 +- packages/credentials/README.i18n.yaml | 4 +- packages/credentials/README.md | 2 +- packages/credentials/README.zh.md | 2 +- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 21 +- .../credentials-local/README.zh.md | 21 +- .../credentials-local/package.json | 4 +- .../credentials-local/src/index.ts | 250 +++++++----------- .../credentials-local/tests/drain.spec.ts | 2 +- .../credentials-local/tests/local.spec.ts | 161 ++++++----- .../tests/review-fixes.spec.ts | 99 ++----- .../credentials-local/tests/watcher.spec.ts | 55 ++-- .../llm-deepseek/tests/dynamic-config.spec.ts | 8 +- .../tests/loader-composition.spec.ts | 20 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 6 +- .../tests/loader-composition.spec.ts | 6 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 5 +- packages/ui/app-boot/README.zh.md | 5 +- packages/ui/app-boot/src/index.ts | 32 ++- packages/ui/app-boot/tests/app-boot.spec.ts | 62 ++++- pnpm-lock.yaml | 12 +- 41 files changed, 566 insertions(+), 423 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md create mode 100644 .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index c7861321a0..ddb3a064d4 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 -2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a +2026-07-29-request-level-llm-config-credentials.md: 5359865d1ca0c6620f4af1fa82c2f7e5413e79d6 +2026-07-29-request-level-llm-config-credentials.zh.md: e23bf92a0d8efa68ad682e002f07732aaa114049 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index f12a2496a7..5359865d1c 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -14,7 +14,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti **Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. -**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was `$DSH_HOME/.env` in dotenv form; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml` and freed the old path to become the user's environment layer. Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. **Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 99fd90013a..e23bf92a0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -14,7 +14,7 @@ Status: implemented **按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 -**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 provider 管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,并让旧路径转为用户的环境层。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 **按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index 98f2b0cb0d..a4ac2f47bb 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa +2026-07-30-credential-boundaries-and-atomic-registration.md: a093a78d7e3dafe218eb8f1013f226de0d6d9a0b +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 208642af34b5bda07a4e02bc991a655c5bb1fa20 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 6fe5f554ac..a093a78d7e 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -14,7 +14,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept ## Decision -**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. +**The credential document belongs to the credential provider alone.** No surface loads it into `process.env`. It was `$DSH_HOME/.env` here; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml`, so today it is the old path that is loaded — as the user's ordinary environment layer, holding no provider-managed secret. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index 3eb3b02206..208642af34 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 决策 -**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 +**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何 provider 管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml new file mode 100644 index 0000000000..eb74fbd0e2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md +2026-08-04-credentials-yaml-and-user-environment-layer.md: f1bca69820d03fe67849bd7c7159489ac27cd2e0 +2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7e6714abd33baad1fb2a570514754b467fcf8bd5 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md new file mode 100644 index 0000000000..f1bca69820 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md @@ -0,0 +1,50 @@ +# Agent Note: Splitting the credential store from the user environment layer + +Status: implemented + +English | [中文](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md) + +## Problem + +`$DSH_HOME/.env` carried two incompatible jobs. It was the writable secret store of [`credentials-local`](../../../../packages/credentials/credentials-local/README.md), so no surface could hoist it into `process.env` — hoisting would make every stored key read as a read-only launch override and block rotation from the TUI and the web page. But its name and dotenv format promise an environment file, so users put non-secrets in it and those values reached nothing: a `DEEPSEEK_BASE_URL` beside a working `DEEPSEEK_API_KEY` in the same file was silently ignored, because only the credential provider read the document and it addresses credential references alone. + +One file cannot be both a store the Harness owns and isolates and a layer that propagates by ordinary environment rules. The [request-level credential decision](2026-07-29-request-level-llm-config-credentials.md) chose dotenv to match peer products' home `.env`, and the conflation was not visible until a non-secret needed the same file. + +## Decision + +The two jobs become two files under the Harness home. + +**`.credentials.yaml` is the provider-managed store.** A strict YAML mapping of `CredentialRef` to non-empty string, with no `version` field and no wrapper level: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +Because the document holds credentials and nothing else, every deviation is a rejection rather than a skipped entry: a non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail — loud at boot and at a write, warn-and-keep-the-last-good-snapshot on a live reload. A silently ignored key would read as "the secret I stored has no effect", which is the failure this change exists to remove. The dotenv physical-line editor is replaced by a patch of the parsed document, so comments and untouched entries keep their formatting, any string value round-trips (multi-line included), and no entry is unwritable for want of a quoting style. The writer lock, read-modify-write, atomic `0600` write under a `0700` directory, exact-path watcher, content-equality self-write suppression, and quiescent disposal are unchanged. + +**`$DSH_HOME/.env` is the user's ordinary environment layer.** `loadLayeredEnv` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) loads the invoking directory's `.env` and then the Harness home's, giving `user < project < inherited` — `process.loadEnvFile` never replaces a name already set, which is what the load order exploits and what the app-boot tests pin across all three layers. The Harness home is resolved from the inherited environment *before* either file loads, so a project `.env` cannot redirect which user document is read. Only the product CLI layers these files; SDK and example bins keep loading their own directory through `loadEnv` and must not inherit a developer's `$DSH_HOME`. + +Credential precedence is unchanged this round: the live process environment still wins read-only over the file, and `set`/`unset` still reject a write the environment would shadow. Whether a provider-managed store should instead win over the environment is a separate decision, deliberately not taken here. + +There is no migration. The product is unreleased, and a key already in `$DSH_HOME/.env` keeps resolving through the new environment layer — as a read-only `env` source that shadows the stored one, which is exactly what the diagnostics say. + +## Consequences + +- Given up: a key left in `$DSH_HOME/.env` is now hoisted into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. That is the honest meaning of "ordinary environment layer"; a secret the Harness should own and isolate belongs in `.credentials.yaml`, which is never hoisted. +- Given up: the same key shadows `.credentials.yaml` and makes the web Models page's write reject. The seam already reports `source: 'env', writable: false` for that state, and the rejection message now names the loaded `.env` as a place to unset it. +- Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. +- Not taken: a read-time permission check that fails startup when `.credentials.yaml` is more permissive than `0600`. Creation and atomic replacement already pin the mode; making a hand-created file fatal is a separable security decision. +- The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral. + +## Alternatives considered + +**Keep one `$DSH_HOME/.env` and teach the CLI to hoist it.** Rejected: hoisting the store is precisely what makes stored keys unrotatable, which is why [app-boot documented the exclusion](../../../../packages/ui/app-boot/README.md) in the first place. The conflict is the file's two jobs, not the loader. + +**`$DSH_HOME/.credentials.env` — a second dotenv file.** Rejected: dotenv suits an environment layer but cannot express "a managed document indexed by credential reference". It cannot reject a non-string or an unaddressable key, and its line editor already refused values it could not quote, leaving entries readable but unwritable. + +**Add a `version` field to the new document.** Rejected: the format is one schema-constrained string mapping with no historical variant to discriminate. While the product is unreleased, changing the structure and rejecting the old one beats promising a migration protocol. + +**Migrate credential-shaped keys out of `$DSH_HOME/.env` on first run.** Rejected: migration code turns a short-lived format into a long-lived maintenance surface, and classifying which keys in an unknown file are secrets is exactly the ambiguity this split removes. The old file keeps working as environment, which is a truthful outcome rather than a silent one. + +**Drop the user `.env` layer entirely and keep only the inherited environment.** Rejected here as out of scope: it is a coherent design (fewer layers, one place per value), but it removes a workflow users have, and the layering question belongs with the deferred precedence decision rather than with this split. diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md new file mode 100644 index 0000000000..7e6714abd3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 把凭据存储与用户环境层拆开 + +Status: implemented + +[English](2026-08-04-credentials-yaml-and-user-environment-layer.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 同时承担了两件互不相容的工作。它是 [`credentials-local`](../../../../packages/credentials/credentials-local/README.md) 的可写密钥存储,因此任何表层都不能把它提升进 `process.env`——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 `DEEPSEEK_API_KEY` 旁边的 `DEEPSEEK_BASE_URL` 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。 + +一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。[请求级凭据决策](2026-07-29-request-level-llm-config-credentials.md)当初选择 dotenv 是为了对齐同类产品的 home `.env`,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。 + +## Decision + +两件工作在 Harness home 下拆成两个文件。 + +**`.credentials.yaml` 是 provider 管理的存储。** 一个从 `CredentialRef` 到非空字符串的严格 YAML mapping,没有 `version` 字段,也没有包装层: + +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、`0700` 目录下的 `0600` 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。 + +**`$DSH_HOME/.env` 是用户的普通环境层。** [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `loadLayeredEnv` 先加载调用目录的 `.env`,再加载 Harness home 的,得到 `用户 < 项目 < 继承`——`process.loadEnvFile` 从不替换已经设置的名字,加载顺序正是利用了这一点,app-boot 的测试也把三层一起钉住。Harness home 在两个文件加载*之前*就从继承的环境解析完毕,因此项目 `.env` 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 `loadEnv` 加载各自的目录,绝不继承开发者的 `$DSH_HOME`。 + +本轮不改凭据优先级:活跃进程环境仍然只读地优先于文件,`set`/`unset` 仍然拒绝会被环境遮蔽的写入。provider 管理的存储是否应当反过来压过环境,是另一个决策,此处刻意不作。 + +不做迁移。产品尚未发布,而已经放在 `$DSH_HOME/.env` 里的密钥会继续通过新的环境层解析——作为只读的 `env` 来源遮蔽已存储的那一份,诊断给出的也正是这个结论。 + +## Consequences + +- 放弃的:留在 `$DSH_HOME/.env` 里的密钥现在会被提升进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。这就是「普通环境层」的诚实含义;需要由 Harness 拥有并隔离的密钥属于 `.credentials.yaml`,后者永不提升。 +- 放弃的:同一个键会遮蔽 `.credentials.yaml`,并让 Web Models 页的写入被拒。seam 对这种状态本来就报告 `source: 'env', writable: false`,而拒绝信息现在会把已加载的 `.env` 一并指为需要清除的位置。 +- 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 +- 未采纳的:在读取时校验权限、并在 `.credentials.yaml` 宽于 `0600` 时让启动失败。创建与原子替换已经钉住了模式;让手工创建的文件直接致命是一个可分离的安全决策。 +- `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。 + +## Alternatives considered + +**保留单一的 `$DSH_HOME/.env`,让 CLI 去提升它。** 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 [app-boot 当初记录该排除](../../../../packages/ui/app-boot/README.md)的理由。冲突来自这个文件的两份工作,而不是加载器。 + +**`$DSH_HOME/.credentials.env`——第二个 dotenv 文件。** 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。 + +**给新文档加 `version` 字段。** 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。 + +**首次运行时把形似凭据的键从 `$DSH_HOME/.env` 迁出。** 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。 + +**彻底取消用户 `.env` 层,只保留继承的环境。** 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 92ea0d2406..515004086e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,7 +52,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | -| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index b7860e2eaa..d46e103426 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -69,12 +69,13 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` +# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml` # (owner-only file, hot-reloaded). Adapters resolve their key references # through it at each request, so no key is inlined in this file. The web -# Models page's key inputs write it through `credentials.set`; nothing hoists -# the document into the process environment, which would make every stored key -# read as an unrotatable ambient override. +# Models page's key inputs write it through `credentials.set`. The document +# holds credentials only and is never hoisted into the process environment; +# the user's ordinary environment layer is `$DSH_HOME/.env`, and a key placed +# there instead reads as an unrotatable ambient override. - id: credentials name: '@deepseek-ai/dsh-credentials-local' diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index e46c8d653a..ba3105c3ef 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -4,9 +4,9 @@ * Everything here is what must exist before the Loader runs: the patch * composition over the shipped base and surface overlay (CLI flags + the * resolved frontend dist), and the fail-loud activation audit after the tree - * settles. The environment is what the bin already loaded (ambient plus the - * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential - * provider and is never hoisted here. + * settles. The environment is what the bin already loaded (ambient over the + * invoking directory's `.env` over `$DSH_HOME/.env`); credentials live in + * `$DSH_HOME/.credentials.yaml` and are never hoisted into it. */ import { readFileSync } from 'node:fs' diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 3886438bed..dd5642de10 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,7 +24,7 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadEnv('dsh') +loadLayeredEnv('dsh') // The env opt-in is read at the process boundary; `1` is the documented value. const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1') diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 5af1a32cbb..f91ea05c4e 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -115,12 +115,11 @@ export async function runTui( ) process.exit(1) } - // The bin already loaded the invoking directory's .env, and that is the - // whole environment: $DSH_HOME/.env is credentials-local's writable store, - // and hoisting it would make every stored key read as a read-only ambient - // override on the next run — unrotatable from the TUI or the web page. - // The environment is settled, so switching the workspace here cannot alter - // its precedence. The cwd IS the workspace seam: the shipped config + // The bin already loaded both environment files, and that is the whole + // environment: credentials live in `$DSH_HOME/.credentials.yaml`, which is + // never hoisted, so a stored key stays rotatable from the TUI and the web + // page. The environment is settled, so switching the workspace here cannot + // alter its precedence — the project layer is the *invoking* directory's. The cwd IS the workspace seam: the shipped config // resolves the session cwd and the HMR watch root from it, so one chdir moves // both together. Sessions themselves live under the Harness home so `/resume` // spans every workspace, and are unaffected by this chdir. diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 17b33f37ce..ade38a0e9c 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -667,40 +667,41 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => { - // The whole personal-config chain in one boot, plus the environment layer - // it deliberately excludes. config.yaml patches the `tui` row — a row the + it('applies the personal overlay: config.yaml patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => { + // The whole personal-config chain in one boot, plus the environment + // layering underneath it. config.yaml patches the `tui` row — a row the // SURFACE OVERLAY inserted, not one the base declares — proving a later - // patch list reaches a row an earlier one inserted. The single `!!js` - // expression prefers the PERSONAL variable, so the welcome can only render - // the project value while the harness home's .env — the credential store - // of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting - // it would make every stored key read as a read-only launch override on - // the next run and hand it to every subprocess the agent starts. + // patch list reaches a row an earlier one inserted. The `!!js` expression + // renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is + // set by BOTH .env files and must render the project value, while + // `DSH_USER_ONLY` exists only in the harness home's .env and must still + // arrive. Credentials are not part of this: they live in + // `.credentials.yaml`, which is never hoisted into `process.env`. const output = await smoke({ label: 'dsh personal overlay', tempDirPrefix: 'dsh-personal-overlay-', binScript: dshBinScript, configArgs: [], prepare: seedWorkspace({ - workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' }, + workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' }, personal: { - '.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n', + '.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n', 'config.yaml': [ '- id: workspace-context', ' disabled: true', '- id: tui', ' config:', " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", - ' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME', + ' welcome: !!js "(process.env.DSH_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')' + + ' + \' \' + (process.env.DSH_USER_ONLY ?? \'USER LAYER MISSING.\')"', '', ].join('\n'), }, }), - actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }], + actions: [{ waitFor: 'PROJECT WINS. USER LAYER LOADED.', send: '/exit\r' }], }) - expect(output).toContain('PROJECT OVERLAY READY.') - expect(output).not.toContain('HOME ENV LEAKED.') + expect(output).toContain('PROJECT WINS. USER LAYER LOADED.') + expect(output).not.toContain('USER LAYER LOST.') expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index c46127c9db..33e27628b0 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -81,7 +81,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the - // key value lands in the harness home's .env, the dormant route + // key value lands in the harness home's .credentials.yaml, the dormant route // registers, and the topology frame invalidates the page into the row. const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) @@ -89,8 +89,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') expect(document).not.toContain('sk-e2e-minimax') - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(stored).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') expect(await page.content()).not.toContain('sk-e2e-minimax') }, 60_000) @@ -136,8 +136,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 10_000 }, ).not.toContain('minimax-cn:') - expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) - .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')) + .toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') await expect.poll( async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(), { timeout: 10_000 }, diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 1ec36454d0..78dd8bf7da 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -112,8 +112,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('button', { name: '保存', exact: true }).click() await keyInput.waitFor({ state: 'detached', timeout: 15_000 }) - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true) + const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true) expect((await page.content()).includes(secret)).toBe(false) expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false) expect(browserConsole.some(line => line.includes(secret))).toBe(false) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 617cfb82be..ab0ca22024 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -412,7 +412,7 @@ Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../package ```ts config-catalog /** Plugin config: file location and hot-reload behavior. */ export interface Config { - /** Credentials document path; defaults to `.env` under the harness home. */ + /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string @@ -423,7 +423,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:35`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 937c976c67..25fc5ea0a2 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -9,7 +9,7 @@ - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` +# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml` # (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` # through it at each request, so no key is inlined in this file. - id: credentials diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml index e8b35ba48e..e62ea8db5c 100644 --- a/packages/credentials/README.i18n.yaml +++ b/packages/credentials/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/README.md -README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 -README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b +README.md: 4ab315e01a30d55869dbbb27dfbaf0f318eadd9f +README.zh.md: 736f7f02eb26b7e0931b676b854dd108fdfae3eb diff --git a/packages/credentials/README.md b/packages/credentials/README.md index 1d450cbeef..4ab315e01a 100644 --- a/packages/credentials/README.md +++ b/packages/credentials/README.md @@ -7,7 +7,7 @@ The credential capability seam, as three-package shape dictates (interface / imp | Package | Role | |---|---| | [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | -| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | +| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.credentials.yaml` (writable, comment-preserving edits, hot-reloaded) | Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md index 843230c3ce..736f7f02eb 100644 --- a/packages/credentials/README.zh.md +++ b/packages/credentials/README.zh.md @@ -7,7 +7,7 @@ | 包 | 角色 | |---|---| | [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | -| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | +| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.credentials.yaml`(可写、保留注释的编辑、热重载)之上 | 配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index b5fb4b2f0e..fc89d359e8 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md -README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86 -README.zh.md: 59c7fd5747f327e8998882ca4db1473173e793b5 +README.md: ca2af9d8a514b43aeef19abec7cda4e44645bdaf +README.zh.md: a8be53629853fe6fb7c39ef2281ac798b5624010 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 02b883958f..ca2af9d8a5 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -7,7 +7,7 @@ File-backed [credentials](../credentials/README.md) provider: two layers, one ho | Layer | Source id | Writable | Wins | |---|---|---|---| | Live process environment | `env` | no | always | -| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | +| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | otherwise | The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. @@ -15,24 +15,33 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, | Field | Default | Meaning | |---|---|---| -| `path` | `/.env` | Credentials document location. | +| `path` | `/.credentials.yaml` | Credentials document location. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | | `watch` | `true` | Hot-publish external edits. | | `debounceMs` | `100` | Watcher write-settle window. | ## The document -dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. +A YAML mapping of credential reference to value, and nothing else: -Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". A non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping. + +Writes patch the parsed document rather than rebuilding it, so comments and the formatting of every untouched entry survive. A comment directly above an entry is that entry's annotation and is removed with it. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. An on-disk document that no longer parses fails the write instead of overwriting content the provider could not understand. + +Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it. ## Hot reload -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)) — so reaching the value takes a deliberate read of a path the agent was not given. That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 59c7fd5747..a8be536298 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -7,7 +7,7 @@ | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| | 活跃进程环境 | `env` | 否 | 恒定优先 | -| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | +| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | 环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 @@ -15,24 +15,33 @@ | 字段 | 默认值 | 含义 | |---|---|---| -| `path` | `/.env` | 凭据文档位置。 | +| `path` | `/.credentials.yaml` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | | `watch` | `true` | 热发布外部编辑。 | | `debounceMs` | `100` | watcher 写入稳定窗口。 | ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 +一个从凭据引用到值的 YAML mapping,除此之外别无他物: -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 +```yaml +DEEPSEEK_API_KEY: sk-… +OPENAI_API_KEY: sk-… +``` + +该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时响亮失败,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。 + +写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖 provider 读不懂的内容。 + +任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。 ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则响亮失败。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))——因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 0b8924d7f2..644904676a 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -35,8 +35,8 @@ }, "dependencies": { "chokidar": "^4.0.3", - "dotenv": "^17.2.0", - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "yaml": "^2.9.0" }, "devDependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c11c2db20c..bc1214d11b 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -1,13 +1,19 @@ /** * File-backed credentials provider layering the live process environment over - * a `$DSH_HOME/.env` document. The environment is authoritative and read-only - * (a launch-time override must win, and must be visibly read-only rather than - * silently shadow writes); the file is the provider-managed writable source: - * every write re-reads the document under a cross-process writer lock before - * rewriting only its own line — preserving every other byte, physical line - * endings and quoted multi-line values included — external edits hot-publish - * through the seam, and each reload replaces the snapshot wholesale so a - * deleted entry never lingers in memory. + * a `$DSH_HOME/.credentials.yaml` document. The environment is authoritative + * and read-only (a launch-time override must win, and must be visibly + * read-only rather than silently shadow writes); the file is the + * provider-managed writable source: every write re-reads the document under a + * cross-process writer lock before patching only its own key — comments and + * the formatting of every untouched entry survive — external edits + * hot-publish through the seam, and each reload replaces the snapshot + * wholesale so a deleted entry never lingers in memory. + * + * The document holds nothing but credentials, which is why it is a strict + * `CredentialRef`-to-string mapping rather than a dotenv file: a store the + * Harness owns and never materializes into the environment cannot also serve + * as the user's environment layer, and conflating the two is what made a + * non-secret in the old `$DSH_HOME/.env` silently unreachable. * @module @deepseek-ai/dsh-credentials-local */ @@ -16,15 +22,18 @@ import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' -import { parse } from 'dotenv' +import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' +/** Basename of the credentials document inside the harness home. */ +export const CREDENTIALS_FILENAME = '.credentials.yaml' + /** Plugin config: file location and hot-reload behavior. */ export interface Config { - /** Credentials document path; defaults to `.env` under the harness home. */ + /** Credentials document path; defaults to `.credentials.yaml` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string @@ -43,13 +52,13 @@ interface ResolvedSpec { /** * Resolve the runtime spec from plugin config: an explicit `path` wins, - * otherwise the document lives at `/.env`. + * otherwise the document lives at `/.credentials.yaml`. * @param config - raw plugin config. * @returns the resolved file location and watch behavior. */ export function resolveSpec(config: Config): ResolvedSpec { return { - filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), + filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), CREDENTIALS_FILENAME)), watch: config.watch ?? true, debounceMs: config.debounceMs ?? 100, } @@ -60,129 +69,64 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Values that survive a dotenv round-trip without quoting. */ -const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ - -/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */ -function hasControlCharacters(value: string): boolean { - for (const char of value) { - if (char.charCodeAt(0) < 0x20) return true +/** + * Parse one credentials document into its entries. The document is a strict + * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a + * key that is not a POSIX identifier, a non-string value, and an empty string + * are all rejected rather than skipped, because this file holds nothing but + * credentials and a silently ignored entry reads as "the key I stored has no + * effect". Duplicate keys surface as parser errors. An empty document is an + * empty store. + * @param text - the document's text. + * @param filename - absolute path, quoted in errors. + * @returns the parsed entries, keyed by reference. + */ +export function parseCredentialsDocument(text: string, filename: string): Map { + const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true }) + if (document.errors.length > 0) { + throw new Error(`credentials-local: invalid document at ${filename}: ${ + document.errors.map(error => error.message).join('; ')}`) } - return false + const root: unknown = document.toJS() ?? {} + if (typeof root !== 'object' || root === null || Array.isArray(root)) { + throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`) + } + const entries = new Map() + for (const [key, value] of Object.entries(root as Record)) { + // credentialRef throws on anything that is not a POSIX identifier, which + // is exactly the constraint a stored reference must satisfy to be + // addressable through the seam. + credentialRef(key) + if (typeof value !== 'string') { + throw new TypeError(`credentials-local: the value for "${key}" in ${filename} must be a string`) + } + if (value.length === 0) { + throw new Error(`credentials-local: the value for "${key}" in ${filename} is empty; remove the key instead`) + } + entries.set(key, value) + } + return entries } /** - * Render one `KEY=value` line in the narrowest style dotenv reads back - * verbatim: bare, then single quotes (fully literal), then double quotes - * (safe only without backslashes, which double-quote reading expands). - * A value no style can represent fails loud instead of corrupting silently. + * Render the next document text with one reference set or deleted. Editing + * the parsed document rather than rebuilding it keeps comments and the + * formatting of every untouched entry; an absent document starts a fresh one. + * @param text - the current document text, `undefined` while the file is absent. + * @param ref - the reference to write. + * @param value - the new value, or `undefined` to delete the key. + * @returns the text to persist. */ -function renderLine(ref: CredentialRef, value: string): string { - if (BARE_VALUE.test(value)) return `${ref}=${value}` - if (hasControlCharacters(value)) { - throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`) - } - if (!value.includes('\'')) return `${ref}='${value}'` - if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"` - throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) +function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string { + // `text` only ever caches content that parsed successfully, so this re-parse + // for the mutable comment-preserving tree cannot fail. + const document = text === undefined ? new Document({}) : parseDocument(text) + if (value === undefined) document.deleteIn([ref]) + else document.setIn([ref], value) + return document.toString() } -/** Split text into physical lines with their terminators attached. */ -function physicalLines(text: string): string[] { - return text.length === 0 ? [] : text.split(/(?<=\n)/) -} - -/** One physical line's content without its terminator. */ -function lineContent(line: string): string { - if (line.endsWith('\r\n')) return line.slice(0, -2) - if (line.endsWith('\n')) return line.slice(0, -1) - return line -} - -/** One physical line's terminator (empty on a final unterminated line). */ -function lineTerminator(line: string): string { - return line.slice(lineContent(line).length) -} - -/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */ -const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ - -/** Quote characters dotenv reads across physical lines. */ -const MULTILINE_QUOTES = ['\'', '"', '`'] - -/** - * The quote character an assignment's value part opens without closing on its - * own line — the following physical lines are that value's continuation, not - * assignments — or `undefined` for a single-line value. - */ -function opensMultiline(valuePart: string): string | undefined { - const trimmed = valuePart.trimStart() - const quote = trimmed[0] - if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined - const rest = trimmed.slice(1) - const body = quote === '"' ? rest.replaceAll('\\"', '') : rest - return body.includes(quote) ? undefined : quote -} - -/** Whether a continuation line closes the given quote. */ -function closesQuote(content: string, quote: string): boolean { - const body = quote === '"' ? content.replaceAll('\\"', '') : content - return body.includes(quote) -} - -/** - * Replace, insert, or delete one reference's assignment while preserving - * every other byte: untouched lines keep their exact content and terminators - * (CRLF included), and the physical lines inside another key's quoted - * multi-line value are never mistaken for assignments. The first matching - * assignment is rewritten in place with its own line ending; later duplicates - * drop (dotenv reads the last one, so a surviving duplicate would override - * the edit); an insert appends in the document's dominant ending style. - */ -function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string { - const lines = physicalLines(text ?? '') - const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n' - const out: string[] = [] - let placed = false - let pendingQuote: string | undefined - for (const line of lines) { - const content = lineContent(line) - if (pendingQuote !== undefined) { - // Inside a quoted multi-line value: never an assignment, always kept. - if (closesQuote(content, pendingQuote)) pendingQuote = undefined - out.push(line) - continue - } - const match = ASSIGNMENT.exec(content) - if (match === null) { - out.push(line) - continue - } - const [, key, valuePart] = match - if (key !== ref) { - /* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */ - pendingQuote = opensMultiline(valuePart ?? '') - out.push(line) - continue - } - // The write path refuses multi-line targets before rendering, so the - // matched assignment is single-line and drops or rewrites wholesale. - if (rendered !== undefined && !placed) { - out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`) - placed = true - } - } - if (rendered !== undefined && !placed) { - const last = out[out.length - 1] - if (last !== undefined && lineTerminator(last) === '') { - out[out.length - 1] = `${last}${dominant}` - } - out.push(`${rendered}${dominant}`) - } - return out.join('') -} - -/** File-backed credentials provider (`$DSH_HOME/.env`). */ +/** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with settings-local (prefer symmetry for parallel values); extracting the shared @@ -273,7 +217,7 @@ export class CredentialsLocal extends Credentials { const env = process.env[ref] if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' }) + if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) return Promise.resolve(undefined) } @@ -283,11 +227,7 @@ export class CredentialsLocal extends Credentials { return Promise.resolve({ configured: true, source: 'env', writable: false }) } const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) { - // A quoted multi-line value resolves fine but the line editor refuses to - // rewrite it, so writability must say what set() would actually do. - return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) - } + if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) return Promise.resolve({ configured: false, writable: true }) } @@ -350,12 +290,7 @@ export class CredentialsLocal extends Credentials { await this.reconcileFromDisk() const existing = this.values.get(ref) if (value === undefined && existing === undefined) return - if (existing !== undefined && existing.includes('\n')) { - throw new Error( - `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, - ) - } - const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) + const nextText = renderDocument(this.text, ref, value) // 0600: a document holding secrets is never world-readable. await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) this.text = nextText @@ -374,12 +309,16 @@ export class CredentialsLocal extends Credentials { if (env !== undefined && env.length > 0) { throw new Error( `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` - + ' shadowed; change the launching environment instead', + + ' shadowed; unset it in the launching environment (or in a loaded .env) instead', ) } } - /** Boot read: an absent file is an empty store; any other failure is loud. */ + /** + * Boot read: an absent file is an empty store; an invalid one fails the + * plugin's activation, because a credentials document that exists but + * cannot be trusted must never be treated as "no credentials stored". + */ private async loadInitial(): Promise { let text: string try { @@ -388,8 +327,8 @@ export class CredentialsLocal extends Credentials { if (!isENOENT(error)) throw error return } + this.values = parseCredentialsDocument(text, this.spec.filename) this.text = text - this.values = new Map(Object.entries(parse(text))) } /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and @@ -415,10 +354,10 @@ export class CredentialsLocal extends Credentials { /** * Compare the on-disk text against the cache and publish any difference - * into the seam. Absence publishes the empty store; an unreadable file - * throws, so each caller picks its policy — a reload warns and keeps the - * last good snapshot, a write fails loud. dotenv parsing is lenient by - * design and cannot fail. + * into the seam. Absence publishes the empty store; an unreadable or + * invalid document throws, so each caller picks its policy — a reload warns + * and keeps the last good snapshot, a write fails loud rather than + * overwriting a document it could not understand. */ private async reconcileFromDisk(): Promise { let text: string | undefined @@ -429,7 +368,7 @@ export class CredentialsLocal extends Credentials { text = undefined } if (text === this.text || this.isClosed()) return - const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) + const next = text === undefined ? new Map() : parseCredentialsDocument(text, this.spec.filename) const changed = this.changedRefs(this.values, next) this.text = text this.values = next @@ -437,21 +376,12 @@ export class CredentialsLocal extends Credentials { } /* jscpd:ignore-end */ - /** Seam-addressable entries whose effective (non-empty) value changed. */ + /** Entries whose stored value changed; the parser has already proven every key addressable. */ private changedRefs(prev: Map, next: Map): CredentialRef[] { const changed: CredentialRef[] = [] for (const key of new Set([...prev.keys(), ...next.keys()])) { - const before = prev.get(key) - const after = next.get(key) - const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined - const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined - if (effectiveBefore === effectiveAfter) continue - try { - changed.push(credentialRef(key)) - } catch (_unaddressableKey) { - // A key that is not a POSIX identifier is preserved file content the - // seam cannot address, so no observer could ever see it change. - } + if (prev.get(key) === next.get(key)) continue + changed.push(credentialRef(key)) } return changed } diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts index baefbd52c5..9cf4e600fb 100644 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -42,7 +42,7 @@ describe('write-drain teardown', () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) cleanups.push(() => rm(dir, { recursive: true, force: true })) const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await fiber const service = ctx.credentials diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 4ebaed1a0c..d5ffddc54d 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -42,29 +42,29 @@ function updates(ctx: Context): CredentialRef[] { } describe('resolveSpec', () => { - it('defaults to .env under the harness home with watching on', () => { + it('defaults to .credentials.yaml under the harness home with watching on', () => { const spec = resolveSpec({ dshHome: '/custom/home' }) - expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + expect(spec).toEqual({ filename: resolve('/custom/home/.credentials.yaml'), watch: true, debounceMs: 100 }) }) it('lets an explicit path win over the home', () => { - const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) - expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + const spec = resolveSpec({ path: '/etc/dsh/creds.yaml', dshHome: '/ignored', watch: false, debounceMs: 5 }) + expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.yaml'), watch: false, debounceMs: 5 }) }) }) describe('layering and reads', () => { it('treats an absent file as an empty writable store', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) }) - it('serves file entries, including export-prefixed and quoted values', async () => { + it('serves file entries alongside comments and quoted values', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n') const ctx = await boot({ path, watch: false }) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) @@ -73,22 +73,22 @@ describe('layering and reads', () => { it('lets a non-empty process environment win read-only over the file', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=from-file\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: from-file\n') const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', 'from-env') expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) }) - it('treats empty values as absent in both layers', async () => { + it('treats an empty environment value as absent, falling through to the file', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) vi.stubEnv('DSH_CRED_TEST', '') - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) }) it('fails boot loud when the document exists but cannot be read', async () => { @@ -100,110 +100,149 @@ describe('layering and reads', () => { }) }) -describe('line-editing writes', () => { - it('appends a missing key to a fresh 0600 document and emits the commit', async () => { +describe('document validation', () => { + // Every rejection below is a boot failure rather than a skipped entry: this + // document holds nothing but credentials, so an ignored key would read as + // "the secret I stored has no effect". + it.each([ + ['a non-mapping root', 'just a string\n', /must be a mapping/], + ['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/], + ['a key that is not a POSIX identifier', 'not-a-ref: value\n', /credential ref/], + ['a non-string value', 'DSH_CRED_TEST: 123\n', /must be a string/], + ['an empty value', 'DSH_CRED_TEST: ""\n', /is empty/], + ['duplicate keys', 'DSH_CRED_TEST: one\nDSH_CRED_TEST: two\n', /invalid document/], + ['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/], + ])('fails boot on %s', async (_case, text, message) => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') + await writeFile(path, text) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(message) + }) + + it('reads an empty document as an empty store', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, '# nothing stored yet\n') + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) +}) + +describe('document writes', () => { + it('adds a missing key to a fresh 0600 document and emits the commit', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) const seen = updates(ctx) await ctx.credentials.set(KEY, 'sk-fresh') - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n') expect((await stat(path)).mode & 0o777).toBe(0o600) expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) expect(seen).toEqual([KEY]) }) - it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { + it('patches one entry, preserving comments and every untouched entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') + const path = join(dir, '.credentials.yaml') + await writeFile(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n') const ctx = await boot({ path, watch: false }) await ctx.credentials.set(KEY, 'new value!') - expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') + expect(await readFile(path, 'utf8')).toBe( + '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: new value!\n', + ) }) - it('quotes hostile values so they round-trip through a fresh provider', async () => { + it('round-trips values no dotenv line could represent', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - const singleQuoted = 'with "quote", back\\slash and space' - const doubleQuoted = "it's got an apostrophe" - await ctx.credentials.set(KEY, singleQuoted) - await ctx.credentials.set(OTHER, doubleQuoted) + const multiLine = 'line one\nline two' + const mixedQuotes = 'both \' and "' + await ctx.credentials.set(KEY, multiLine) + await ctx.credentials.set(OTHER, mixedQuotes) const reread = await boot({ path, watch: false }) - expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) - expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) + expect(await reread.credentials.resolve(KEY)).toEqual({ value: multiLine, source: 'file' }) + expect(await reread.credentials.resolve(OTHER)).toEqual({ value: mixedQuotes, source: 'file' }) + expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) }) - it('fails loud on values no .env quoting style reads back verbatim', async () => { + it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) - await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) - }) - - it('unsets only the owning line and keeps an absent unset silent', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') + const path = join(dir, '.credentials.yaml') + // Comments above an entry are that entry's annotation and go with it when + // it is removed — including anything above the document's first entry. + // Every other entry keeps its own comments. + await writeFile(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n') const ctx = await boot({ path, watch: false }) const seen = updates(ctx) await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') + expect(await readFile(path, 'utf8')).toBe('# about the survivor\nDSH_CRED_OTHER: stays\n') await ctx.credentials.unset(KEY) expect(seen).toEqual([KEY]) }) - it('rejects empty values, shadowed writes, and multi-line entries', async () => { + it('rejects empty values and writes the environment would shadow', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') const ctx = await boot({ path, watch: false }) await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) - await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) - await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) vi.stubEnv('DSH_CRED_TEST', 'shadowing') await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) }) - it('leaves an empty document after unsetting the only entry', async () => { + it('leaves an empty mapping after unsetting the only entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=only\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: only\n') const ctx = await boot({ path, watch: false }) await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('') + expect(await readFile(path, 'utf8')).toBe('{}\n') + // The emptied document still reloads as an empty store, not a parse error. + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(KEY)).toBeUndefined() + }) + + it('fails a write loud when the on-disk document became invalid', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + const ctx = await boot({ path, watch: false }) + // An external editor left the document unparsable: the read-modify-write + // must refuse rather than overwrite content it cannot understand. + await writeFile(path, 'DSH_CRED_TEST: "unterminated\n') + await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/) }) it('chains past a rejected write so one bad value cannot poison the queue', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + const bad = expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) const good = ctx.credentials.set(OTHER, 'lands') await bad await good - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER: lands\n') }) it('serializes concurrent writes so both land in the one document', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) await Promise.all([ ctx.credentials.set(KEY, 'one'), ctx.credentials.set(OTHER, 'two'), ]) - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: one\nDSH_CRED_OTHER: two\n') }) it('refuses writes after disposal', async () => { const dir = await tempDir() const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await fiber // Capture the handle first: disposal also removes the ctx.credentials service. const service = ctx.credentials @@ -215,20 +254,20 @@ describe('line-editing writes', () => { describe('real hot reload', () => { it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') // Watching starts on an existing document: creation racing watcher setup // is a chokidar readiness gap, not the reload contract under test. - await writeFile(path, 'DSH_CRED_TEST=boot\n') + await writeFile(path, 'DSH_CRED_TEST: boot\n') const ctx = await boot({ path, debounceMs: 10 }) const seen = updates(ctx) - await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n') + await writeFile(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n') await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) }) // Wholesale replacement: an entry deleted on disk never lingers in memory. - await writeFile(path, 'DSH_CRED_TEST=live\n') + await writeFile(path, 'DSH_CRED_TEST: live\n') await vi.waitFor(async () => { expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index 78e51c90e1..7d2f447e5a 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -1,7 +1,7 @@ // Third-review behaviors: read-modify-write under the writer lock (external // edits survive an API write), the contained credentials/updated fan-out (a -// broken observer never fails a committed write), and the physical-line -// editor's multi-line and CRLF discipline. +// broken observer never fails a committed write), and the YAML document +// editor's isolation between entries. import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' @@ -37,18 +37,18 @@ async function boot(config: ConstructorParameters[1]): describe('read-modify-write', () => { it('folds an unobserved external edit into a write instead of overwriting it', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { seen.push(ref) }) await ctx.credentials.set(ALPHA, 'one') // The external edit has landed on disk but no watcher reported it (watch // is off — the same blind spot as a debounce window or a missed event). - await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`) + await writeFile(path, `${ALPHA}: one\n${BETA}: external\n`) await ctx.credentials.set(ALPHA, 'two') const text = await readFile(path, 'utf8') - expect(text).toContain(`${BETA}=external`) - expect(text).toContain(`${ALPHA}=two`) + expect(text).toContain(`${BETA}: external`) + expect(text).toContain(`${ALPHA}: two`) // The fold published the unobserved entry before the write's own commit. expect(seen).toEqual([ALPHA, BETA, ALPHA]) expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' }) @@ -56,7 +56,7 @@ describe('read-modify-write', () => { it('keeps both refs when two providers write the same document concurrently', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const first = await boot({ path, watch: false }) const second = await boot({ path, watch: false }) await Promise.all([ @@ -71,7 +71,7 @@ describe('read-modify-write', () => { it('creates the credentials directory owner-only', async () => { const dir = await tempDir() const home = join(dir, 'home') - const ctx = await boot({ path: join(home, '.env'), watch: false }) + const ctx = await boot({ path: join(home, '.credentials.yaml'), watch: false }) await ctx.credentials.set(ALPHA, 'one') expect((await stat(home)).mode & 0o777).toBe(0o700) }) @@ -80,7 +80,7 @@ describe('read-modify-write', () => { describe('contained update fan-out', () => { it('does not fail a committed set when a listener throws, and later listeners still run', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) ctx.on('credentials/updated', () => { throw new Error('observer boom') }) @@ -93,7 +93,7 @@ describe('contained update fan-out', () => { it('contains an async listener rejection', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false }) // An unknown-returning function keeps the typed surface legal while the // runtime value is still the rejected promise the containment must handle. const boom = (): unknown => Promise.reject(new Error('async observer boom')) @@ -104,7 +104,7 @@ describe('contained update fan-out', () => { it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) ctx.on('credentials/updated', () => { throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) @@ -114,78 +114,33 @@ describe('contained update fan-out', () => { await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/) // Harness-fatal by design — but the write itself committed first. expect(second).toHaveBeenCalledWith(ALPHA) - expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`) + expect(await readFile(path, 'utf8')).toContain(`${ALPHA}: one`) expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) }) }) -describe('physical-line editor', () => { - it('never mistakes a quoted multi-line continuation for an assignment', async () => { +describe('document editor', () => { + it('leaves a sibling multi-line value untouched while patching one entry', async () => { const dir = await tempDir() - const path = join(dir, '.env') - const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n` + const path = join(dir, '.credentials.yaml') + const wrapped = `DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: a\n` await writeFile(path, wrapped) const ctx = await boot({ path, watch: false }) await ctx.credentials.set(ALPHA, 'b') - // The wrapped value survives byte-for-byte; only ALPHA's line changed. - const afterAlpha = await readFile(path, 'utf8') - expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`) - // Setting the inner-looking ref appends a real assignment; the - // continuation line inside the quoted value stays untouched. - await ctx.credentials.set(INNER, 'real') - const afterInner = await readFile(path, 'utf8') - expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`) - expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' }) + expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n line1\n line2\n${ALPHA}: b\n`) + expect(await ctx.credentials.resolve(credentialRef('DSH_REVIEW_WRAPPED'))) + .toEqual({ value: 'line1\nline2', source: 'file' }) }) - it('preserves CRLF line endings on untouched and edited lines', async () => { + it('stores a value that looks like another entry without creating one', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`) + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`) - await ctx.credentials.set(INNER, 'new') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`) - }) - - it('terminates a final unterminated line before appending', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(BETA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`) - }) - - it('rewrites a final unterminated assignment in the dominant ending style', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`) - }) - - it('tracks a single-quoted multi-line value through its continuation', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'x') - expect(await readFile(path, 'utf8')) - .toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`) - }) - - it('reports a multi-line entry as unwritable and refuses to edit it', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}="line1\nline2"\n`) - const ctx = await boot({ path, watch: false }) - expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false }) - await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/) - await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/) - // Resolution still serves the multi-line value. - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' }) + // The stored text must stay a value: a quoted-scalar write that leaked its + // own structure would silently mint a credential nobody stored. + await ctx.credentials.set(ALPHA, `${INNER}: injected`) + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(ALPHA)).toEqual({ value: `${INNER}: injected`, source: 'file' }) + expect(await reread.credentials.resolve(INNER)).toBeUndefined() }) }) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 6ff53252cf..8f34b09868 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -66,21 +66,21 @@ async function boot(config: ConstructorParameters[1]): describe('watcher pipeline', () => { it('clamps the write-settle poll interval for a zero debounce', async () => { const dir = await tempDir() - await boot({ path: join(dir, '.env'), debounceMs: 0 }) + await boot({ path: join(dir, '.credentials.yaml'), debounceMs: 0 }) const [instance] = await fakeInstances() expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) }) it('survives a watcher error and keeps publishing later edits', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) const [instance] = await fakeInstances() instance!.watcher.emit('error', new Error('watch backend failure')) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - await writeFile(path, 'DSH_CRED_PIPE=arrived\n') + await writeFile(path, 'DSH_CRED_PIPE: arrived\n') instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) @@ -89,8 +89,8 @@ describe('watcher pipeline', () => { it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=good\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: good\n') const ctx = await boot({ path, debounceMs: 5 }) await chmod(path, 0o000) @@ -104,7 +104,7 @@ describe('watcher pipeline', () => { it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) let arm = true ctx.on('credentials/updated', () => { @@ -113,7 +113,7 @@ describe('watcher pipeline', () => { }) const [instance] = await fakeInstances() - await writeFile(path, 'DSH_CRED_PIPE=first\n') + await writeFile(path, 'DSH_CRED_PIPE: first\n') instance!.watcher.emit('all', 'change', path) // The snapshot commits before the fan-out, so the value lands even though // the listener threw out of the refresh. @@ -122,7 +122,7 @@ describe('watcher pipeline', () => { }) arm = false - await writeFile(path, 'DSH_CRED_PIPE=second\n') + await writeFile(path, 'DSH_CRED_PIPE: second\n') instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) @@ -131,8 +131,8 @@ describe('watcher pipeline', () => { it('quiesces the refresh pipeline before dispose completes', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=initial\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: initial\n') const ctx = new Context() const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) await fiber @@ -142,7 +142,7 @@ describe('watcher pipeline', () => { if (disposed) postDisposeCommits += 1 }) - await writeFile(path, 'DSH_CRED_PIPE=changed\n') + await writeFile(path, 'DSH_CRED_PIPE: changed\n') const [instance] = await fakeInstances() // Two queued refreshes: dispose interrupts one mid-flight and the other // before it starts, so both closed guards must hold. @@ -158,8 +158,8 @@ describe('watcher pipeline', () => { it('empties the snapshot when the document is deleted and emits the removals', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=doomed\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: doomed\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { @@ -175,30 +175,39 @@ describe('watcher pipeline', () => { expect(seen).toEqual([KEY]) }) - it('publishes only seam-addressable keys and preserves the rest untouched', async () => { + it('keeps the last good snapshot when an external edit makes the document invalid', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_PIPE: a\n') const ctx = await boot({ path, debounceMs: 5 }) const seen: string[] = [] ctx.on('credentials/updated', (ref) => { seen.push(ref) }) - await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') + // A key the seam cannot address is a rejection, not preserved content: + // this document holds nothing but credentials. A live reload must warn + // and keep serving the last good snapshot rather than take the process + // down or silently drop the entry it could not validate. + await writeFile(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n') const [instance] = await fakeInstances() instance!.watcher.emit('all', 'change', path) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'a', source: 'file' }) + expect(seen).toEqual([]) + + // Repairing the document resumes publishing. + await writeFile(path, 'DSH_CRED_PIPE: b\n') + instance!.watcher.emit('all', 'change', path) await vi.waitFor(async () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) }) - // The dash-named key is preserved file content the seam cannot address: - // its change publishes nothing and breaks nothing. expect(seen).toEqual([KEY]) }) it('treats an event for a still-absent file as a no-op', async () => { const dir = await tempDir() - const path = join(dir, '.env') + const path = join(dir, '.credentials.yaml') const ctx = await boot({ path, debounceMs: 5 }) const [instance] = await fakeInstances() instance!.watcher.emit('all', 'add', path) @@ -208,12 +217,12 @@ describe('watcher pipeline', () => { it('reconciles at watcher ready so a change during setup is not missed', async () => { const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${KEY}=a\n`) + const path = join(dir, '.credentials.yaml') + await writeFile(path, `${KEY}: a\n`) const ctx = await boot({ path, debounceMs: 5 }) // Written after the initial load but before the watcher became active: // no 'all' event will ever fire for it. - await writeFile(path, `${KEY}=written-before-ready\n`) + await writeFile(path, `${KEY}: written-before-ready\n`) const [instance] = await fakeInstances() instance!.watcher.emit('ready') await vi.waitFor(async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 25cf4f293b..6aecdcdaf7 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -48,7 +48,7 @@ async function boot(dir: string, config: object): Promise { await ctx.plugin(LlmService) const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmDeepSeek, config) return { ctx, settingsFiber } } @@ -61,7 +61,7 @@ describe('request-level dynamic configuration', () => { it('routes the next request with the freshly resolved base URL and credential', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n') const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: serverA.url }) @@ -81,7 +81,7 @@ describe('request-level dynamic configuration', () => { it('prefers a literal settings apiKey over the credential layers', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: file-key\n') const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: server.url }) @@ -178,7 +178,7 @@ describe('request-level dynamic configuration', () => { it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n') + await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n') const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 402f94441d..c8d596af74 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -2,7 +2,7 @@ * Real-composition guard for the dynamic-configuration chain: LlmService, * settings-local, credentials-local, and llm-deepseek boot from a test-only * cordis.yml through the actual Loader + Include path, external edits of - * settings.yaml and .env hot-publish through their providers, and the very + * settings.yaml and the credentials document hot-publish through their providers, and the very * next request carries the fresh base URL and credential. The same adapter * composition without settings or credentials entries keeps entry-config * behavior — the documented optional-inject fallback. @@ -42,16 +42,16 @@ afterEach(async () => { async function loadComposition( options: { withDynamic: boolean; baseURL: string; reuseRoot?: string }, -): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { +): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> { // A reused root is the restart case: the same harness home, its documents // exactly as the previous process left them. const fresh = options.reuseRoot === undefined root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) const settingsPath = join(root, 'settings.yaml') - const envPath = join(root, '.env') + const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') - await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') + await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n') } const configPath = join(root, 'cordis.yml') @@ -68,7 +68,7 @@ async function loadComposition( '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(envPath)}`, + ` path: ${JSON.stringify(credentialsPath)}`, ' debounceMs: 10', ] : [], @@ -103,15 +103,15 @@ async function loadComposition( config: { path: pathToFileURL(configPath).href }, }) await ctx.loader.await() - return { ctx, settingsPath, envPath } + return { ctx, settingsPath, credentialsPath } } describe('llm-deepseek real dynamic composition', () => { - it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => { + it('boots from cordis.yml and routes the next request after external settings and credential edits', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) - const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) + const { ctx, settingsPath, credentialsPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -122,7 +122,7 @@ describe('llm-deepseek real dynamic composition', () => { await vi.waitFor(() => { expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) - await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n') + await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n') await vi.waitFor(async () => { expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) }, { timeout: 5000 }) @@ -134,7 +134,7 @@ describe('llm-deepseek real dynamic composition', () => { it('keeps a stored key writable and rotatable across a real restart', async () => { // No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist - // $DSH_HOME/.env into process.env, so a stored key must stay file-sourced. + // the credentials document into process.env, so a stored key must stay file-sourced. vi.stubEnv('DEEPSEEK_API_KEY', '') const first = await mockServer([{ kind: 'sse', events: textEvents }]) const second = await mockServer([{ kind: 'sse', events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d13234f8db..2c60ba0e83 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -44,7 +44,7 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { }) await ctx.plugin(LlmService) await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmPiAi, config) return ctx } @@ -53,7 +53,7 @@ describe('request-level dynamic profiles', () => { it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n') + await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n') const server = await mockServer([{ events: textEvents }]) // The exact product posture: `- id: llm-pi-ai` with no config at all. const ctx = await boot(dir, {}) @@ -112,7 +112,7 @@ describe('request-level dynamic profiles', () => { it('rotates the per-request credential referenced by apiKeyEnv', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n') + await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n') const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const ctx = await boot(dir, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 460e78b7c2..5d32a748ea 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -3,7 +3,7 @@ * settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a * test-only cordis.yml through the actual Loader + Include path, an external * edit of settings.yaml registers the route live, and the next request - * carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot + * carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot * catch Loader export-shape failures, which is why the twin adapter has the * same guard. */ @@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) const settingsPath = join(root, 'settings.yaml') await writeFile(settingsPath, '# personal settings\n') - await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n') + await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -54,7 +54,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(join(root, '.env'))}`, + ` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`, ' debounceMs: 10', '- id: llm-pi-ai', " name: '@deepseek-ai/dsh-llm-pi-ai'", diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index d565f6f11c..be3bb757a4 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 7e0466c40583e6f5b22e0d5ef25d211d595c3216 -README.zh.md: abb796aaa9fd6f8e6ee0578423382ed7f23909ab +README.md: 8636af748168f6d898d7b44da298636af3686001 +README.zh.md: 0d956a3f5734cd04694fb96a6c89468e99413ebc diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 7e0466c405..8636af7481 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,6 +8,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | The `dsh` product CLI's user environment: `loadEnv` over the invoking directory, then over the Harness home, giving `user < project < inherited`. The home is resolved from the inherited environment first, so a project `.env` cannot redirect it | | `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | @@ -33,7 +34,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: -- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. +- **`.env`** — the user's ordinary environment layer, loaded by the `dsh` bin through `loadLayeredEnv` beneath the invoking directory's `.env` and the inherited environment. It is plain environment with plain environment reach, not a secret boundary: what the Harness owns and isolates lives in `.credentials.yaml`, which no surface hoists. A key placed in this file therefore still resolves — as a read-only `env` layer that shadows the stored one and blocks rotation from the TUI and the web page. - **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. @@ -52,5 +53,5 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. -- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. +- **Environment loading is directory-scoped and optional** — each layer is one named directory's `.env`, and a failure warns; neither helper searches parents or validates required variables. `loadLayeredEnv` fixes its two layers at the invoking directory and the Harness home, so a caller wanting different layers composes `loadEnv` itself. - **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index abb796aaa9..0d956a3f57 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,6 +8,7 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | +| `loadLayeredEnv(binName, cwd?, warn?)` | `dsh` 产品 CLI(命令行界面)的用户环境:先对调用目录、再对 Harness home 调用 `loadEnv`,得到 `用户 < 项目 < 继承` 的层次。Harness home 先从继承的环境解析,因此项目 `.env` 无法改变它的指向 | | `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) | | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | @@ -33,7 +34,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: -- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`.env`**:用户的普通环境层,由 `dsh` bin 经 `loadLayeredEnv` 加载,位于调用目录的 `.env` 与继承环境之下。它是具有普通环境作用域的普通环境值,而不是密钥边界:由 Harness 拥有并隔离的东西放在 `.credentials.yaml` 里,后者不会被任何表层提升。因此放进本文件的密钥仍然可以解析——但会作为只读的 `env` 层遮蔽已存储的那一份,并阻断从 TUI 与 Web 页面轮换密钥。 - **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 @@ -52,5 +53,5 @@ TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPa - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 -- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 +- **环境加载按目录划分且为可选操作**:每一层都是一个指定目录下的 `.env`,失败时发出警告;两个 helper 都不会搜索父目录,也不验证必需变量。`loadLayeredEnv` 的两层固定为调用目录与 Harness home,需要其他层次的调用方请自行组合 `loadEnv`。 - **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 7f3579cda1..94a7aa2c7d 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,6 +1,6 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored - * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the + * `.env` files, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot @@ -65,6 +65,36 @@ export function loadEnv( } } +/** + * Load the dsh product CLI's user environment: the invoking directory's `.env` + * over the Harness home's `.env`, both under the inherited process + * environment. `process.loadEnvFile` never replaces a name that is already + * set, so loading the project file first and the user file second is what + * makes the layering `user < project < inherited`; the app-boot tests pin all + * three layers because that ordering is the whole contract. + * + * The Harness home is resolved from the inherited environment *before* either + * file loads, so a project `.env` can never redirect which user document is + * read. Only the product CLI layers these files: an SDK or example bin loads + * its own directory through {@link loadEnv} and must not inherit a developer's + * `$DSH_HOME`. + * + * These are ordinary environment values with ordinary environment reach. A + * secret the Harness should own and isolate belongs in the credentials + * document, which is never materialized here. + * @param binName - the diagnostic prefix on the warn lines. + * @param cwd - the invoking directory whose `.env` is the project layer. + * @param warn - sink for the one-line misconfiguration diagnostics. + */ +export function loadLayeredEnv( + binName: string, cwd: string = process.cwd(), + warn: (line: string) => void = line => void process.stderr.write(line), +): void { + const home = resolveDshHome() + loadEnv(binName, cwd, warn) + loadEnv(binName, home, warn) +} + /** File inside the Harness home holding the personal loader overlay patches. */ export const PERSONAL_CONFIG_FILENAME = 'config.yaml' diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 96cad31ea3..ece98a9716 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -7,7 +7,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -86,6 +86,66 @@ describe('loadEnv', () => { }) }) +describe('loadLayeredEnv', () => { + const NAMES = ['DSH_APP_BOOT_LAYERED_SHARED', 'DSH_APP_BOOT_LAYERED_USER', 'DSH_APP_BOOT_LAYERED_PROJECT'] as const + + function clear(): void { + for (const name of NAMES) Reflect.deleteProperty(process.env, name) + } + + it('layers user under project under the inherited environment', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), [ + `${NAMES[0]}=user`, + `${NAMES[1]}=user-only`, + 'DSH_APP_BOOT_LAYERED_INHERITED=user-loses', + '', + ].join('\n')) + writeFileSync(join(project, '.env'), [ + `${NAMES[0]}=project`, + `${NAMES[2]}=project-only`, + 'DSH_APP_BOOT_LAYERED_INHERITED=project-loses', + '', + ].join('\n')) + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('DSH_APP_BOOT_LAYERED_INHERITED', 'inherited') + const warn = vi.fn() + try { + loadLayeredEnv(NAME, project, warn) + // Both files load; the project layer wins the name they share, and the + // inherited environment wins over both. + expect(process.env[NAMES[0]]).toBe('project') + expect(process.env[NAMES[1]]).toBe('user-only') + expect(process.env[NAMES[2]]).toBe('project-only') + expect(process.env['DSH_APP_BOOT_LAYERED_INHERITED']).toBe('inherited') + expect(warn).not.toHaveBeenCalled() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('resolves the harness home before the project file can redirect it', () => { + const home = tmp() + const decoy = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`) + writeFileSync(join(decoy, '.env'), `${NAMES[1]}=decoy-home\n`) + writeFileSync(join(project, '.env'), `DSH_HOME=${decoy}\n`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + loadLayeredEnv(NAME, project, vi.fn()) + expect(process.env[NAMES[1]]).toBe('real-home') + } finally { + clear() + vi.unstubAllEnvs() + } + }) +}) + describe('installFailLoud', () => { function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } { const handlers: Array<(err: unknown) => void> = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e27760b0d..a74fc2677f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2625,12 +2625,12 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 - dotenv: - specifier: ^17.2.0 - version: 17.4.2 schemastery: specifier: ^3.18.0 version: link:../../../vendor/schemastery + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -9776,10 +9776,6 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -14832,8 +14828,6 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dotenv@17.4.2: {} - dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 From 8ddc53f7a036acb3efcf0a26827e04bbe6830430 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 15:25:04 +0800 Subject: [PATCH 03/88] feat(cli)!: complete --config on every surface and delete the personal overlay $DSH_HOME/config.yaml was an implicit composition layer: if the file existed, every launch applied an arbitrary Loader patch graph over the shipped tree, kept live by a dedicated HMR watcher. Three costs came from the implicitness, not the capability. A patch replaces its target row's whole config, so a file written months ago pins that row to the field set it knew and every default the shipped tree later adds silently stops applying. It competed with the typed settings namespaces llm-deepseek and llm-pi-ai already register, so which one wins was a function of layer order rather than meaning. And the explicit escape hatch it was supposedly redundant with did not exist on every surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them the implicit file was the only composition route at all. Complete the explicit layer first: --config and --config-replace now work on every booting surface. A headless --config-replace tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; AppCLIEntry names that contract in the failure instead of reporting a bare missing service. Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches, watchPersonalPatches, and the config-only HMR row mounted for it are gone; a file left at that path is inert, and --dump-config no longer reads the Harness home. --config therefore stops *replacing* the personal overlay and simply *is* the user overlay. No migration: a user who wants the old behavior names the same file (dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent. --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 4 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 4 +- ...7-29-shared-base-config-overlays.i18n.yaml | 4 +- .../2026-07-29-shared-base-config-overlays.md | 4 +- ...26-07-29-shared-base-config-overlays.zh.md | 4 +- ...emove-personal-composition-layer.i18n.yaml | 6 + ...08-04-remove-personal-composition-layer.md | 47 +++ ...04-remove-personal-composition-layer.zh.md | 47 +++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 12 +- apps/cli/README.zh.md | 12 +- apps/cli/config/base.cordis.yml | 4 +- apps/cli/src/app-cli-entry.ts | 95 +++--- apps/cli/src/args.ts | 113 +++++--- apps/cli/src/bin.ts | 6 +- apps/cli/src/dump-config.ts | 25 +- apps/cli/src/headless.ts | 10 +- apps/cli/src/tui.ts | 48 ++-- apps/cli/src/web.ts | 3 +- apps/cli/tests/args.spec.ts | 26 +- apps/cli/tests/built-bin.e2e.ts | 16 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 52 ++-- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 2 +- docs/user/guide/config.zh.md | 2 +- examples/mcp-memory/README.i18n.yaml | 4 +- examples/mcp-memory/README.md | 2 +- examples/mcp-memory/README.zh.md | 2 +- .../cordis/repository-plugin/README.i18n.yaml | 4 +- packages/cordis/repository-plugin/README.md | 4 +- .../cordis/repository-plugin/README.zh.md | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 18 +- packages/ui/app-boot/README.zh.md | 18 +- packages/ui/app-boot/src/index.ts | 150 ++-------- .../ui/app-boot/tests/config-dump.spec.ts | 12 +- .../ui/app-boot/tests/config-reload.spec.ts | 16 +- .../ui/app-boot/tests/personal-config.spec.ts | 270 ------------------ 39 files changed, 416 insertions(+), 650 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md create mode 100644 .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md delete mode 100644 packages/ui/app-boot/tests/personal-config.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index e4e9dfb93a..8bdb3fc8c0 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 1fa8cda2b34b58cc7a28b722872520b68a9b7009 -2026-07-20-dsh-cli-personal-config.zh.md: e70b8914cf005e0a2e54ba2b29d3b7def84b00db +2026-07-20-dsh-cli-personal-config.md: 3770fdbcac038874c8beb3071217ef40942f8dfe +2026-07-20-dsh-cli-personal-config.zh.md: dcecf8749b29fd1023516570490adf2b256d0b35 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 1fa8cda2b3..3770fdbcac 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -17,7 +17,7 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim: - `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`. -- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice. +- `config.yaml` — [removed with the personal composition layer](../simplification/2026-08-04-remove-personal-composition-layer.md); while it existed, a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwarded it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice. - A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip). The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. @@ -46,4 +46,4 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real dsh bin with no overlay, a personal environment and UI patch, a config-only cached repository skill, and invalid personal YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. +The overlay's own spec covered parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches; it was deleted with the layer. `apps/cli/tests/tui-keyless-smoke.e2e.ts` still boots the real dsh bin with no overlay, with a named `--config` environment and UI patch, with a config-only cached repository skill, and with invalid overlay YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index e70b8914cf..dcecf8749b 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -17,7 +17,7 @@ Status: implemented **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: - `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 -- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 +- `config.yaml`——[已随个人 composition 层一并删除](../simplification/2026-08-04-remove-personal-composition-layer.md);它存在期间是顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 @@ -46,4 +46,4 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 启动真实 dsh bin,覆盖无 overlay、个人环境与 UI patch、纯配置的缓存 repository skill,以及无效个人 YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 +该 overlay 自己的 spec 曾固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留;它已随该层一并删除。`apps/cli/tests/tui-keyless-smoke.e2e.ts` 仍然启动真实 dsh bin,覆盖无 overlay、点名 `--config` 的环境与 UI patch、纯配置的缓存 repository skill,以及无效的 overlay YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index b100535da6..90523174f6 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: ee642cbc786bef708791fb58e655c5a3f0e9c4e7 -2026-07-29-shared-base-config-overlays.zh.md: b7fc9c6b121b8d0eb94d734af6bda6df45e25b1d +2026-07-29-shared-base-config-overlays.md: 494adcfc9efe2c88a67efd8a7ad2e5e0a2a39b4d +2026-07-29-shared-base-config-overlays.zh.md: 919350db03420f9a5190c96e02fe774b6d2cb346 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index ee642cbc78..494adcfc9e 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -18,9 +18,9 @@ One shared base, one overlay per surface, composed as sibling patch lists. `apps/cli/config/base.cordis.yml` holds the 43 rows both surfaces mount. `apps/cli/config/tui.cordis.yml` and `apps/cli/config/web.cordis.yml` are **patch lists**, not trees: each states the handful of rows whose value is surface-specific and inserts its own rows. The launcher includes the base once and applies every overlay as a sibling patch list at **one** include level, because include patches never cross an include boundary — stacking overlays as nested includes would silently stop reaching base rows. -Precedence is list order, last write winning per row: base, then the surface overlay, then either a `--config` overlay or the personal `~/.dsh/config.yaml`, then the launcher's own flag and profile patches. +Precedence is list order, last write winning per row: base, then the surface overlay, then a `--config` overlay, then the launcher's own flag patches. The personal `~/.dsh/config.yaml` sat in the `--config` slot until it was [removed with the personal composition layer](../simplification/2026-08-04-remove-personal-composition-layer.md). -`--config ` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. +`--config ` applies an overlay over the shipped tree (at the time, **instead of** the personal overlay, so a demo or test tree never inherited the user's provider and model). `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index b7fc9c6b12..919350db03 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -18,9 +18,9 @@ Status: implemented `apps/cli/config/base.cordis.yml` 持有两个 surface 都会挂载的 43 个配置项。`apps/cli/config/tui.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 是 **patch 列表**,不是配置树:各自声明少数取值因 surface 而异的配置项,并 insert 自己的配置项。启动器只 include base 一次,并把每个 overlay 作为**同一** include 层级上的平级 patch 列表应用——因为 include patch 不会跨越 include 边界,把 overlay 堆叠成嵌套 include 会使其静默地无法触达 base 配置项。 -优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay 或个人 `~/.dsh/config.yaml`,最后是启动器自身的 flag 与 profile patch。 +优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay,最后是启动器自身的 flag patch。个人 `~/.dsh/config.yaml` 曾占据 `--config` 这一槽位,直到它[已随个人 composition 层一并删除](../simplification/2026-08-04-remove-personal-composition-layer.md)。 -`--config ` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 +`--config ` 在已交付配置树上应用一个 overlay(当时是**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model)。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml new file mode 100644 index 0000000000..11239d3c23 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md +2026-08-04-remove-personal-composition-layer.md: 941e2248e15e235037e6bd48dcb3ba6c80bd83dd +2026-08-04-remove-personal-composition-layer.zh.md: 6c6f3ecd541590368624f4ed4bd409321a2f9772 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md new file mode 100644 index 0000000000..941e2248e1 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md @@ -0,0 +1,47 @@ +# Agent Note: Removing the personal composition layer + +Status: implemented + +English | [中文](2026-08-04-remove-personal-composition-layer.zh.md) + +## Problem + +`$DSH_HOME/config.yaml` was an implicit composition layer: if the file existed, every `dsh` launch applied an arbitrary Loader patch graph over the shipped tree, and the TUI and Web kept it live through a dedicated HMR watcher. Three costs followed from the implicitness rather than from the capability. + +A patch replaces its target row's whole `config`, so a personal file written months ago pins that row to the field set it knew. Every default the shipped tree later adds to that row silently stops applying, and nothing surfaces it short of running `--dump-config`. Applying that on every launch turns a one-time edit into a standing divergence. + +It also competed with typed settings for the same values. `llm-deepseek` and `llm-pi-ai` register settings namespaces, and the same fields are reachable by patching their rows — so which one wins is a function of layer order, not of what the value means. That is the ownership ambiguity the [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) exists to remove. + +Finally the escape hatch it was supposed to be redundant with did not cover every surface: `dsh -p`, `dsh meta`, and `dsh upgrade` all rejected `--config`. For those surfaces the implicit file was not one composition route among two — it was the only one. + +## Decision + +The implicit layer is deleted and the explicit one is completed. + +**Every booting surface takes `--config` and `--config-replace`.** `dsh -p`, `dsh meta`, and `dsh upgrade` join the TUI, so naming a tree is available wherever a tree boots. A headless `--config-replace` tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; `AppCLIEntry` now names that contract in the failure instead of reporting a bare missing service. + +**`$DSH_HOME/config.yaml` is not read, watched, or dumped.** `PERSONAL_CONFIG_FILENAME`, `loadPersonalPatches`, `watchPersonalPatches`, and the config-only HMR row mounted for it are deleted. A file left at that path is inert. The Harness home keeps `settings.yaml`, `.credentials.yaml`, and `.env`; an overlay may still live there, but as a path to name, not a layer to discover. + +`--config` therefore changes meaning slightly: it used to *replace* the personal overlay, and now it simply *is* the user overlay. `--config-replace` is unchanged. + +Everyday capabilities keep their owners. Model and provider parameters already belong to the adapters' typed settings namespaces. The `repository-plugins` row ships mounted with an empty list, so a repository Plugin list is a `--config` overlay today and a settings namespace when one lands. MCP servers stay a `--config` composition, which is what [the CLI README](../../../../apps/cli/README.md) now documents. + +There is no migration and no deprecation diagnostic: the product is unreleased, and a user who wants the old behavior names the same file (`dsh --config ~/.dsh/config.yaml`), which a shell alias makes permanent. + +## Consequences + +- Given up: a composition that follows you across launches without being named. Restoring it is an alias, which is the point — the graph is now something a launch declares rather than something the machine holds. +- Given up: live reload of a composition file. Settings and credentials keep their own watchers; a composition change now takes a restart, which is what `--config` already meant for every explicit tree. +- Bought: one composition route instead of two, a shipped tree that cannot be silently pinned to a stale field set, and typed settings as the uncontested owner of the values they declare. +- The [personal-config feature note](../feature/2026-07-20-dsh-cli-personal-config.md) is only partially superseded — the `dsh` CLI it introduced stands — so both notes stay cross-linked and its config-overlay facts were rewritten in place. +- `--dump-config` prints the shipped base, the surface overlay, and any named `--config`; with no flag it prints the shipped composition alone, so the Harness home no longer changes what a dump shows. + +## Alternatives considered + +**Keep the file but stop watching it.** Rejected: the watcher is the smaller half. The standing cost is that an old patch list silently pins a shipped row on every launch, which a startup-only read preserves exactly. + +**Name the overlay from `settings.yaml` (`compositionOverlay: ~/.dsh/my.cordis.yml`).** Rejected, and worth stating because it looks like the best of both: it keeps the runtime property that motivated the removal — every launch applies an arbitrary plugin graph — and only changes the trigger from "file exists" to "field is set". Worse, `settings.yaml` is written by the product's own settings UI, so it would let a settings page edit the composition tree. + +**Delete it only after the settings-driven repository and MCP managers exist.** Rejected as an unnecessary dependency once `--config` reached every surface: the managers make those two cases *nicer*, but with the flag available everywhere, nothing is lost by removing the implicit layer first. + +**Keep it for `dsh -p` alone, where no flag existed.** Rejected: that is the surface with the strongest case for explicitness. A CI or scripted run should name its composition rather than inherit whatever the machine holds. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md new file mode 100644 index 0000000000..6c6f3ecd54 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 删除个人 composition 层 + +Status: implemented + +[English](2026-08-04-remove-personal-composition-layer.md) | 中文 + +## Problem + +`$DSH_HOME/config.yaml` 是一个隐式的 composition 层:只要该文件存在,每次 `dsh` 启动都会在已交付配置树上应用一张任意的 Loader patch 图,而 TUI 与 Web 还用一个专门的 HMR watcher 让它保持热更新。随之而来的三项代价来自「隐式」,而不是来自这项能力本身。 + +patch 会替换目标行的整个 `config`,因此几个月前写下的个人文件会把那一行钉死在它当时知道的字段集上。此后交付端给该行新增的每个默认值都会静默失效,而除非跑 `--dump-config`,否则没有任何东西会暴露这一点。每次启动都应用它,等于把一次性编辑变成了长期偏离。 + +它还在同一批值上与类型化 settings 争夺所有权。`llm-deepseek` 与 `llm-pi-ai` 都注册了 settings namespace,而同样的字段也能通过 patch 它们的行抵达——于是谁赢取决于层序,而不取决于这个值的语义。这正是 [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) 要消除的所有权歧义。 + +最后,本应与它互为冗余的那条显式通道并未覆盖所有界面:`dsh -p`、`dsh meta` 和 `dsh upgrade` 都拒绝 `--config`。对这些界面来说,隐式文件不是两条 composition 路径之一——它是唯一的一条。 + +## Decision + +删掉隐式的那一层,并把显式的那一层补完整。 + +**每个会启动的界面都接受 `--config` 与 `--config-replace`。** `dsh -p`、`dsh meta` 和 `dsh upgrade` 与 TUI 看齐,因此只要有配置树启动的地方,就能点名一棵树。无头模式下的 `--config-replace` 树仍必须挂载 webserver 行,因为该界面是通过浏览器所用的同一个 HTTP 网关访问自己的 agent 的;`AppCLIEntry` 现在会在失败信息里说明这条契约,而不是只报告某个服务缺失。 + +**`$DSH_HOME/config.yaml` 不再被读取、监视或 dump。** `PERSONAL_CONFIG_FILENAME`、`loadPersonalPatches`、`watchPersonalPatches`,以及专为它挂载的那一行 config-only HMR,全部删除。留在该路径上的文件是惰性的。Harness home 仍然保有 `settings.yaml`、`.credentials.yaml` 和 `.env`;overlay 也仍然可以放在那里,但它是一条待点名的路径,而不是一层待发现的配置。 + +因此 `--config` 的含义略有变化:它过去是*替代*个人 overlay,现在它本身*就是*用户 overlay。`--config-replace` 保持不变。 + +日常能力各自保有归属。模型与 provider 参数已经属于各适配器的类型化 settings namespace。`repository-plugins` 行随交付配置以空列表挂载,因此仓库插件列表今天是一个 `--config` overlay,等 settings namespace 落地后归它。MCP 服务器仍然是 `--config` composition,这也是 [CLI README](../../../../apps/cli/README.md) 现在的写法。 + +不做迁移,也不给弃用诊断:产品尚未发布,想要旧行为的用户点名同一个文件即可(`dsh --config ~/.dsh/config.yaml`),配一个 shell alias 就是永久的。 + +## Consequences + +- 放弃的:一份无需点名就跨启动跟随你的 composition。恢复它只需一个 alias,而这正是重点——插件图现在由一次启动声明,而不是由机器持有。 +- 放弃的:composition 文件的热重载。settings 与凭据各自保留 watcher;composition 变更现在需要重启,而这本来就是 `--config` 对每一棵显式树的既有含义。 +- 换来的:只有一条 composition 路径而不是两条;已交付配置树不会被静默钉死在陈旧字段集上;类型化 settings 成为其所声明的值的唯一所有者。 +- [个人配置特性 Note](../feature/2026-07-20-dsh-cli-personal-config.md) 只被部分取代——它引入的 `dsh` CLI(命令行界面)仍然成立——因此两条 Note 保持互链,其中关于 config overlay 的事实已就地改写。 +- `--dump-config` 打印已交付基座、surface overlay 以及任何被点名的 `--config`;不带标志时只打印已交付组合,因此 Harness home 不再改变 dump 的内容。 + +## Alternatives considered + +**保留该文件,只是不再监视它。** 否决:watcher 是较小的那一半。长期代价在于一份旧 patch 列表会在每次启动时静默钉死一个已交付行,而只在启动时读取恰恰完整保留了这一点。 + +**从 `settings.yaml` 里点名 overlay(`compositionOverlay: ~/.dsh/my.cordis.yml`)。** 否决,且值得写明,因为它看起来两全其美:它保留了促成本次删除的那条运行时性质——每次启动都应用一张任意插件图——只是把触发条件从「文件存在」换成「字段已设置」。更糟的是,`settings.yaml` 由产品自己的设置界面写入,那等于让设置页面能编辑 composition 树。 + +**等 settings 驱动的 repository 与 MCP manager 落地后再删。** 在 `--config` 覆盖所有界面之后,这条依赖已无必要,故否决:那两个 manager 会让这两种场景*更好用*,但只要标志处处可用,先删掉隐式层就不损失任何东西。 + +**只为 `dsh -p` 保留它,因为那里原本没有标志。** 否决:那恰恰是最需要显式的界面。CI 或脚本化运行应当点名自己的 composition,而不是继承机器上恰好存在的东西。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 96a4588f2c..44cb46a317 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 76d9ed65398322cb9244a31661ee59b60c23f793 -README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b +README.md: 3195fb4856ec794186658afd5e329cd58e6a3b28 +README.zh.md: 011cacb347aff88f6a04544dcd9b5e9b8d434c18 diff --git a/apps/cli/README.md b/apps/cli/README.md index 76d9ed6539..3195fb4856 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -7,11 +7,11 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad The TUI surface: -- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; +- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay over that tree, while `--config-replace ` boots the named file as the complete tree; every booting surface takes both flags; - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; -- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only. +- reads the Harness home (`~/.dsh`) for user state only (see [app-boot's Harness home](../../packages/ui/app-boot/README.md#the-harness-home)): `.env` is the user environment layer and `.credentials.yaml` is the credential provider's own store, never hoisted into the environment, so keys stay rotatable. Environment precedence is ambient > project `.env` > user `.env`. No composition file is discovered there: an overlay reaches a launch only through `--config`. - presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. - registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. @@ -19,13 +19,13 @@ The TUI surface: `dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options beyond the experimental gate — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. -`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. +`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and any `--config` overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. -The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then any `--config ` overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one. -All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command: +Every surface reads its `--config` overlay once at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command, by naming an overlay such as `dsh --config ~/.dsh/plugins.yml`: ```yaml - id: repository-plugins @@ -53,7 +53,7 @@ pnpm run dsh web --config apps/cli/config/core-web.cordis.yml Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). -MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout: +MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from a `--config` overlay without a source checkout: ```yaml - insert: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 16a7a4ec52..011cacb347 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -7,11 +7,11 @@ Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([` TUI 界面: -- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; +- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 在该树之上应用一个补丁列表覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动;每个会启动的界面都接受这两个标志; - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; -- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。 +- 只把 Harness home(`~/.dsh`)当作用户状态来读取(参见 [app-boot 的 Harness home](../../packages/ui/app-boot/README.md#the-harness-home)):`.env` 是用户环境层,`.credentials.yaml` 是凭据 provider 自己的存储,绝不会被提升进环境,因此密钥始终可轮换。环境优先级为环境中已有的值 > 项目 `.env` > 用户 `.env`。那里不会发现任何 composition 文件:overlay 只能通过 `--config` 抵达一次启动。 - 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 - 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 @@ -19,13 +19,13 @@ TUI 界面: `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令除实验性门槛外不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 -`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 +`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及任何 `--config` 覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 -Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用任何 `--config ` 覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI,但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下,宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle,因此刷新现有 URL 即可更新该 GUI,无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR(热模块替换)接收端,但要实现无刷新更新,还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundle;shell 和普通包(package)的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset(`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器;TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。 -三个界面都会使用 `$DSH_HOME/config.yaml`;TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件: +每个界面都只在启动时读取自己的 `--config` 覆盖。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只要点名一个覆盖文件(例如 `dsh --config ~/.dsh/plugins.yml`)即可添加已准备的 GitHub 插件: ```yaml - id: repository-plugins @@ -53,7 +53,7 @@ pnpm run dsh web --config apps/cli/config/core-web.cordis.yml 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 -MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml` 或 `--config` 覆盖层挂载你自己的服务器: +MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `--config` 覆盖层挂载你自己的服务器: ```yaml - insert: diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index d46e103426..aea2f8934c 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -1,7 +1,7 @@ # The shared `dsh` core: every row both the TUI (`tui.cordis.yml`) and the web # surface (`web.cordis.yml`) mount identically. Neither surface includes the # other — each is a patch list applied over THIS file at one include level, so a -# surface overlay, a `--config` overlay, and the personal `~/.dsh/config.yaml` +# surface overlay and an explicit `--config` overlay # all address these rows by id. Patch lists stack in that order, last write # winning per row. # @@ -22,7 +22,7 @@ config: root: ['.'] -# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub +# A `--config` overlay replaces this row's config to select exact GitHub # repository Plugin generations. The app registers the DSH-owned runtime even # when the list is empty so a later personal-config edit can load # transactionally; one-shot headless runs consume the startup value only. diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index ba3105c3ef..95776484d0 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -16,13 +16,7 @@ import { resolve } from 'node:path' import { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { - boot, - installFailLoud, - loadOverlayPatches, - loadPersonalPatches, - watchPersonalPatches, -} from '@deepseek-ai/dsh-app-boot' +import { boot, installFailLoud, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -117,16 +111,18 @@ export interface AppCLIEntryOptions { * fields on the same row. */ overlayPath: string - /** - * Optional explicit overlay applied after {@link overlayPath} and before - * this entry's own flag patches. When absent, the personal - * `$DSH_HOME/config.yaml` overlay is applied instead. - */ + /** Optional `--config` overlay applied after {@link overlayPath} and before this entry's own flag patches. */ extraOverlayPath?: string + /** + * Optional `--config-replace` tree: booted INSTEAD of {@link configPath}, + * {@link overlayPath}, {@link extraOverlayPath}, and every generated patch, + * so the caller's file is the whole composition. It must still supply the + * serving rows this entry needs — {@link run} rejects a settled tree with no + * `httpServer`. + */ + configReplacePath?: string /** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */ dev: boolean - /** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */ - watchPersonalConfig: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ host?: string /** @@ -176,8 +172,15 @@ export class AppCLIEntry { await this.bootTree() this.assertBoot() const port = this.ctx.get('httpServer')?.port - /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ - if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot') + if (port === undefined) { + // The shipped tree always carries the webserver row, so this is only + // reachable through --config-replace: name the missing contract rather + // than report a bare missing service. + throw new Error( + `dsh: no httpServer after booting ${this.bootConfigPath()}; this surface serves over HTTP, so a` + + ' --config-replace tree must mount a webserver row', + ) + } return { ctx: this.ctx, port } } @@ -188,6 +191,16 @@ export class AppCLIEntry { */ private composePatches(): void { const rows = this.parseYmlRows() + if (this.options.configReplacePath !== undefined) { + // A replacement tree is the caller's whole composition: the generated + // patches target shipped row ids this file cannot assume exist, and a + // patch whose id is absent is a silent no-op rather than a diagnostic. + // Telemetry stays, judged against the tree actually booting, because a + // privacy switch that silently no-ops is worse than a loud one. + const replaceTelemetry = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) + this.patches = replaceTelemetry === undefined ? [] : [replaceTelemetry] + return + } const overrides = new Map>() const put = (entryId: string, key: string, value: unknown): void => { const bag = overrides.get(entryId) ?? {} @@ -230,31 +243,26 @@ export class AppCLIEntry { // One include of the shared base with every overlay as a sibling patch // list: patches never cross an include boundary, so nesting them would // silently stop reaching base rows. The surface overlay applies first, then - // this entry's CLI-flag patches, which therefore win. - const compose = (overlay: PatchOptions[]): PatchOptions[] => [ - ...loadOverlayPatches('dsh', this.options.overlayPath), - ...overlay, - ...this.patches, - ] - // An explicit --config overlay REPLACES the personal overlay, so there is - // then no personal layer to keep live — the watcher is personal-only. - const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined - const patches = compose( - this.options.extraOverlayPath === undefined - ? loadPersonalPatches('dsh') ?? [] - : loadOverlayPatches('dsh', this.options.extraOverlayPath), - ) - this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => { + // any --config overlay, then this entry's CLI-flag patches, which win. + // --config-replace discards all three and boots the named file alone. + const patches = this.options.configReplacePath !== undefined + ? this.patches + : [ + ...loadOverlayPatches('dsh', this.options.overlayPath), + ...this.options.extraOverlayPath === undefined + ? [] + : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ...this.patches, + ] + this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => { await this.options.prepare?.(ctx) - // Config-only HMR for the personal overlay: module reload stays off for - // this surface (web.cordis.yml disables the shared `hmr` row until its - // reload lifecycle is tested), so this row watches no module roots. - if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) - if (watchPersonal) { - await watchPersonalPatches(this.ctx, { binName: 'dsh', compose }) - } + } + + /** The file the Loader includes: the replacement tree when named, otherwise the shared base. */ + private bootConfigPath(): string { + return this.options.configReplacePath ?? this.options.configPath } /** Install the diagnostic for plugin rejections that happen after settled boot. */ @@ -270,6 +278,17 @@ export class AppCLIEntry { */ private parseYmlRows(): Map { const rows = new Map() + // A replacement tree stands alone, so only its own rows are indexed — + // the telemetry-row check must judge the tree that actually boots. + if (this.options.configReplacePath !== undefined) { + for (const row of this.parseRowList(this.options.configReplacePath)) { + if (typeof row.id === 'string') rows.set(row.id, row) + for (const inserted of row.insert ?? []) { + if (typeof inserted.id === 'string') rows.set(inserted.id, inserted) + } + } + return rows + } const files = [this.options.configPath, this.options.overlayPath] if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath) for (const file of files) { diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 19bc58ccd4..e2ef70bd10 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -16,8 +16,8 @@ import { Command, CommanderError } from 'commander' /** * Interactive TUI: the default mode. `--config` applies an overlay over the - * shipped composition in place of the personal one, `--config-replace` boots a - * file as the whole tree instead, and `--resume ` rehydrates a session. + * shipped composition, `--config-replace` boots a file as the whole tree + * instead, and `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' @@ -28,40 +28,49 @@ interface TuiInvocation { /** * Print the composed config tree and exit, without booting: `--dump-config` - * composes the shipped base, the surface overlay, and the `--config` or - * personal overlay — exactly the layers that surface would boot; - * `--dump-default-config` stops at the surface overlay (the shipped tree, no - * user layer). + * composes the shipped base, the surface overlay, and any `--config` overlay — + * exactly the layers that surface would boot; `--dump-default-config` stops at + * the surface overlay (the shipped tree, no user layer). */ interface DumpConfigInvocation { mode: 'dump-config' surface: 'tui' | 'web' - /** Omit the `--config`/personal layer and print only the shipped composition. */ + /** Omit the `--config` layer and print only the shipped composition. */ defaultOnly: boolean - /** The `--config` overlay to compose instead of the personal one. */ + /** The `--config` overlay to compose over the shipped tree. */ config?: string } -/** Headless one-shot: `dsh -p "task"`. */ +/** + * Headless one-shot: `dsh -p "task"`. `--config` and `--config-replace` mean + * exactly what they mean for the TUI, so an automated run can name its + * composition instead of depending on whatever the machine happens to hold. + */ interface HeadlessInvocation { mode: 'headless' prompt: string + config?: string + configReplace?: string } -/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */ +/** Interactive fresh TUI over this harness checkout; takes the composition flags and the experimental gate. */ interface MetaInvocation { mode: 'meta' + config?: string + configReplace?: string } /** * Guided fresh-session entry: `dsh upgrade` seeds the first turn - * with the `dsh-upgrade` skill. It always mints a - * fresh session in the invoking directory and takes no options beyond the - * experimental gate — `--resume`, `--config`, and `-p` are rejected as - * mistyped, so there is nothing to carry. + * with the `dsh-upgrade` skill. It always mints a fresh session in the + * invoking directory, so `--resume` and `-p` are rejected as mistyped; the + * composition flags are accepted because the update runs against whatever + * tree the caller names. */ interface SkillSessionInvocation { mode: 'upgrade' + config?: string + configReplace?: string } /** @@ -184,9 +193,9 @@ Examples: // subcommand without a positional collision. .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') .option('--resume ', 'continue a past session by id') - .option('--config ', 'apply this overlay of loader patches instead of the personal one') - .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') - .option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit') + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped configuration') + .option('--dump-config', 'print the composed config tree (base + surface + --config overlay) and exit') .option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit') .action((options: { config?: string @@ -208,23 +217,24 @@ Examples: } if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to - // run, and --config/--resume are TUI inputs that must not silently - // vanish from a headless run. + // run, and --resume is a TUI input that must not silently vanish from + // a one-shot run. The composition flags DO apply: naming a tree is how + // an automated run pins its composition. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config, --config-replace, or --resume') + if (options.resume !== undefined) program.error('error: --prompt takes no --resume') + assertOneConfigFlag(options) + resolved = { + mode: 'headless', + prompt: options.prompt, + ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, } - resolved = { mode: 'headless', prompt: options.prompt } return } // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') - // The two config flags are mutually exclusive: one layers over the shipped - // tree, the other discards it, so accepting both would silently drop one. - if (options.config !== undefined && options.configReplace !== undefined) { - program.error('error: --config and --config-replace are mutually exclusive') - } + assertOneConfigFlag(options) resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, @@ -233,10 +243,27 @@ Examples: } }) + /** + * The two config flags are mutually exclusive on every surface that takes + * them: one layers over the shipped tree, the other discards it, so + * accepting both would silently drop one. + * @param options - the parsed options of the surface being resolved. + */ + function assertOneConfigFlag(options: { config?: string; configReplace?: string }): void { + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } + } + + /** The composition flags every booting surface registers, in one place so their help text cannot drift. */ + const withConfigFlags = (command: Command): Command => command + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped configuration') + // Commander parses the parent (default-surface) options on either side of a - // subcommand into `program.opts()`. For a subcommand that shares none of them, - // a leaked config/prompt/resume option is a mistyped invocation that must fail - // loud rather than silently run and drop the input. + // subcommand into `program.opts()`. A subcommand takes its own flags after + // its own name, so a leaked parent config/prompt/resume option is a mistyped + // invocation that must fail loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { const parent = program.opts<{ config?: string @@ -267,14 +294,18 @@ Examples: // come last. `upgrade` is a guided fresh-session entry: beyond the // experimental gate it takes no options and always mints a fresh session, // so nothing is left to carry. - program - .command('upgrade') + withConfigFlags(program.command('upgrade')) .description('update this dsh installation to the latest version (experimental)') .option('--experimental', 'acknowledge this subcommand is experimental') - .action((options: { experimental?: boolean }) => { + .action((options: { experimental?: boolean; config?: string; configReplace?: string }) => { rejectParentOptions('upgrade') requireExperimental('upgrade', options.experimental) - resolved = { mode: 'upgrade' } + assertOneConfigFlag(options) + resolved = { + mode: 'upgrade', + ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, + } }) // Host and port name no default: the CLI passes neither through when the flag @@ -288,7 +319,7 @@ Examples: .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') - .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit') + .option('--dump-config', 'print the composed config tree (base + web + --config overlay) and exit') .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit') .action((options: WebOptions) => { rejectParentOptions('web') @@ -300,14 +331,18 @@ Examples: resolved = resolveWeb(options) }) - program - .command('meta') + withConfigFlags(program.command('meta')) .description('work on the dsh source that runs this command, from any directory (experimental)') .option('--experimental', 'acknowledge this subcommand is experimental') - .action((options: { experimental?: boolean }) => { + .action((options: { experimental?: boolean; config?: string; configReplace?: string }) => { rejectParentOptions('meta') requireExperimental('meta', options.experimental) - resolved = { mode: 'meta' } + assertOneConfigFlag(options) + resolved = { + mode: 'meta', + ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, + } }) try { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index dd5642de10..bdef3205b9 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -36,7 +36,7 @@ switch (invocation.mode) { } case 'headless': { const { runHeadless } = await import('./headless.ts') - await runHeadless(invocation.prompt) + await runHeadless(invocation.prompt, invocation.config, invocation.configReplace) break } case 'tui': { @@ -51,12 +51,12 @@ switch (invocation.mode) { } case 'meta': { const { runTui, SOURCE_ROOT } = await import('./tui.ts') - await runTui(undefined, undefined, SOURCE_ROOT) + await runTui(invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace) break } case 'upgrade': { const { runTui } = await import('./tui.ts') - await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`) + await runTui(invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace) break } default: diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 39a87c2dc8..80022a0efb 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -1,7 +1,7 @@ /** * `dsh --dump-config` / `dsh web --dump-config` — print the composed config * tree without booting: the shipped base, the surface overlay, and (unless - * `--dump-default-config`) the `--config` or personal overlay, composed + * `--dump-default-config`) any `--config` overlay, composed * through the include's own patch algorithm so the printed tree is exactly * what that surface would mount. `!!js` expressions print verbatim, * unevaluated — the dump shows composition, not one process's environment. @@ -10,16 +10,13 @@ * @module @deepseek-ai/dsh/dump-config */ -import { basename, join } from 'node:path' +import { basename } from 'node:path' import { fileURLToPath } from 'node:url' import { loadOverlayPatches, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' const NAME = 'dsh' @@ -36,25 +33,17 @@ const SURFACE_OVERLAYS = { * separator naming the file each section of rows comes from (and the layers * that patched it). * @param surface - which surface overlay to compose over the shared base. - * @param defaultOnly - stop at the surface overlay (no `--config`/personal layer). - * @param config - the `--config` overlay path composed instead of the personal - * one, or `undefined` to use `$DSH_HOME/config.yaml`. + * @param defaultOnly - stop at the surface overlay (no `--config` layer). + * @param config - the `--config` overlay path to compose over the shipped + * tree, or `undefined` for the shipped composition alone. */ export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void { const overlay = SURFACE_OVERLAYS[surface] const layers: ConfigDumpLayer[] = [ { label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) }, ] - if (!defaultOnly) { - if (config === undefined) { - const personal = loadPersonalPatches(NAME) - // The personal file may be absent; the shipped layers still print. - if (personal !== undefined) { - layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal }) - } - } else { - layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) - } + if (!defaultOnly && config !== undefined) { + layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) } process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers)) } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index e41bc03c6c..5864604e05 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -9,6 +9,7 @@ */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -71,14 +72,19 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` * (the adapter rejects an empty task, so no guard is needed here). * @param task - the prompt text for the single turn. + * @param config - a `--config` overlay applied over the shipped composition, or `undefined`. + * @param configReplace - a `--config-replace` tree booted instead of the + * shipped composition, or `undefined`. It must mount a webserver row: this + * surface reaches its own agent over the same HTTP gateway the browser uses. */ -export async function runHeadless(task: string): Promise { +export async function runHeadless(task: string, config?: string, configReplace?: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, + ...configReplace !== undefined && { configReplacePath: resolveConfigPath(configReplace, undefined) }, dev: false, - watchPersonalConfig: false, port: 0, }) const { ctx, port } = await entry.run() diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f91ea05c4e..20981dc068 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,9 +1,9 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * shared base and TUI overlay, followed by either `--config` or the personal overlay - * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: - * ambient environment, then the invoking directory's `.env`, then the personal one) - * and its `config.yaml` patches the booted tree. The workspace is the invoking + * shared base and TUI overlay, followed by any `--config` overlay. The Harness + * home (`~/.dsh`) contributes the user environment layer only: its `.env` fills + * environment gaps (precedence: ambient environment, then the invoking + * directory's `.env`, then the user one). The workspace is the invoking * directory: the session cwd, relative paths, and workspace instructions resolve * from it, so `dsh` acts on whatever project it is launched in. Session storage * is the exception — it lives under the Harness home so `/resume` reaches every @@ -26,9 +26,7 @@ import { boot, installFailLoud, loadOverlayPatches, - loadPersonalPatches, resolveConfigPath, - watchPersonalPatches, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { PatchOptions } from '@cordisjs/plugin-include' @@ -77,13 +75,12 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; - the CLI PTY smoke drives this path end to end, personal overlay included */ + the CLI PTY smoke drives this path end to end, --config overlay included */ /** * Run the interactive TUI from the invoking directory. * @param config - an overlay patch list applied over the shared base and the - * TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never - * inherits the user's route, or `undefined` to use the personal overlay; - * already parsed from `--config`. + * TUI overlay, or `undefined` for the shipped composition alone; already + * parsed from `--config`. * @param resumeSessionId - a persisted session id to resume, or `undefined` to * mint a fresh one; already parsed and non-empty-validated from `--resume`. * Either way the resulting identity reaches the booted app through @@ -95,9 +92,9 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * first turn, or `undefined`. Set only by `dsh upgrade` and * ignored on a resume, so it never re-fires; reaches the app through * {@link INITIAL_SKILL_KEY}. - * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the - * shared base, the TUI overlay, and the personal overlay alike, or `undefined` - * to compose them; already parsed from `--config-replace`. + * @param configReplace - a config path to boot as the ENTIRE tree, bypassing + * the shared base and the TUI overlay alike, or `undefined` to compose them; + * already parsed from `--config-replace`. */ export async function runTui( config: string | undefined, @@ -202,10 +199,8 @@ export async function runTui( // patch list: patches never cross an include boundary, so stacking these as // nested includes would silently stop reaching base rows. Later lists win. // - // `--config` REPLACES the personal overlay rather than layering under it: an - // explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a - // demo or test config would silently run on the user's provider and model. - // `--config-replace` additionally discards the base and the surface overlay. + // `--config` layers over the shipped base and TUI overlay; `--config-replace` + // discards both and boots the named file alone. const replaceTree = configReplace !== undefined const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined) // Same opt-out semantics as the web surface (resolveTelemetryPatch: any @@ -214,16 +209,13 @@ export async function runTui( // presence is checked against the tree actually booting, so a // --config-replace tree is judged on its own rows, not the shipped base's. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig)) - const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [ + const patches: PatchOptions[] = [ ...replaceTree ? [] : [ ...loadOverlayPatches(NAME, TUI_OVERLAY), - ...resolvedConfig === undefined - ? personalPatches - : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), + ...resolvedConfig === undefined ? [] : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)), ], ...telemetryPatch === undefined ? [] : [telemetryPatch], ] - const patches = composePatches(loadPersonalPatches(NAME) ?? []) const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, @@ -243,8 +235,8 @@ export async function runTui( // the Harness home across every cwd, so /resume sees every workspace. // The bundle treats the slot as opaque. // The agent-loop row reads this to bind `main`, and the tui row reads the - // same id, so a personal overlay repointing the model route cannot drop - // the session identity or desynchronise the two. + // same id, so an overlay repointing the model route cannot drop the + // session identity or desynchronise the two. hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) // The query database is a disposable derived index with single-process // ownership. Keep it process-local while it indexes the shared logs. @@ -264,14 +256,6 @@ export async function runTui( } }, ) - // The shipped tree includes HMR and keeps personal config live. An explicit - // --config tree replaces the personal overlay (so there is nothing to keep - // live), and a --config-replace or HMR-less tree remains a valid composition - // that still receives the startup overlay but deliberately has no hidden - // watcher. - if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) { - await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches }) - } app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) if (showFirstRunWelcome) { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 4fbfba4d8d..a3dc446706 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -91,7 +91,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. * @param config - an overlay of loader patches applied over the shipped web - * composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the + * composition, or `undefined` to boot the * personal overlay; already parsed from `--config`. */ export async function runWeb( @@ -109,7 +109,6 @@ export async function runWeb( ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) }, - watchPersonalConfig: true, ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 5b0e76323d..a9f7d228bf 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -30,6 +30,17 @@ describe('parseDshArgs', () => { expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // Every booting surface takes the composition flags: with the personal + // overlay gone, naming a tree is the only way to compose one, so a + // surface that could not name one would have no composition path at all. + expect(parse(['-p', 'task', '--config', 'c.yml'])) + .toEqual({ mode: 'headless', prompt: 'task', config: 'c.yml' }) + expect(parse(['-p', 'task', '--config-replace', 'tree.yml'])) + .toEqual({ mode: 'headless', prompt: 'task', configReplace: 'tree.yml' }) + expect(parse(['meta', '--experimental', '--config', 'c.yml'])) + .toEqual({ mode: 'meta', config: 'c.yml' }) + expect(parse(['upgrade', '--experimental', '--config-replace', 'tree.yml'])) + .toEqual({ mode: 'upgrade', configReplace: 'tree.yml' }) // Experimental subcommands run under the per-invocation flag or the env opt-in. expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' }) expect(parse(['meta'], true)).toEqual({ mode: 'meta' }) @@ -77,9 +88,8 @@ describe('parseDshArgs', () => { // schema at boot, not here.) expect(exitCode(['--resume='])).toBe(1) expect(exitCode(['-p', ''])).toBe(1) - expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) expect(exitCode(['bogus-positional'])).toBe(1) @@ -91,16 +101,14 @@ describe('parseDshArgs', () => { expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for each subcommand that shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and always starts fresh, - // so every default-surface option is rejected. + // `meta` always starts fresh, so the session options are rejected; the + // composition flags are its own and only their combination is rejected. expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1) - expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1) - // `upgrade` takes no options beyond the gate: any leaked default-surface - // flag is a mistyped invocation, not a silently-dropped input. + expect(exitCode(['meta', '--experimental', '--config', 'c.yml', '--config-replace', 't.yml'])).toBe(1) + // `upgrade` always mints a fresh session, so `--resume` and a leaked + // parent flag are mistyped invocations; its own composition flags are not. expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1) - expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1) expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1) // The pre-release command names have no compatibility aliases. expect(exitCode(['experimental-meta'])).toBe(1) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 3592d438dd..67bccd8048 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -107,8 +107,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == tui.cordis.yml') }, 30_000) - it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => { - writeFileSync(join(home, 'config.yaml'), [ + it('layers a --config overlay in --dump-config and reports an unmatched patch on stderr', async () => { + const overlay = join(home, 'overlay.yml') + writeFileSync(overlay, [ '- id: agent-loop', ' config:', ' agents:', @@ -120,16 +121,19 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ' value: 1', '', ].join('\n')) - const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) + const { stdout, code, stderr } = await runBuiltBin(['--dump-config', '--config', overlay], { DSH_HOME: home }) expect(code).toBe(0) expect(stdout).toContain('provider: custom-provider') expect(stdout).not.toContain('model: deepseek-v4-pro') - // The personal layer appears in the patched row's provenance and the + // The named layer appears in the patched row's provenance and the // skipped-patch warning carries its label. - expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`) + expect(stdout).toContain(`patched by tui.cordis.yml, ${overlay}`) expect(stderr).toContain('patch: entry "only-on-web" not found') - // The shipped view ignores the personal overlay entirely. + // An unnamed dump composes the shipped tree only: a file sitting in the + // Harness home is not a layer any more. + const unnamed = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) + expect(unnamed.stdout).not.toContain('custom-provider') const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) expect(shipped.stdout).not.toContain('custom-provider') expect(shipped.stdout).toContain('model: deepseek-v4-pro') diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index ade38a0e9c..2894d0a1ca 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -40,7 +40,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib' : LOADER_SMOKE_TEST_TIMEOUT_MS /** - * Seed the isolated process workspace: ordinary files land in `cwd`, personal + * Seed the isolated process workspace: ordinary files land in `cwd`, harness * files in the Harness home (`.dsh`), and skill bundles under the agents * home's `skills/` root — the same trees `$DSH_HOME` / * `$DSH_AGENTS_HOME` point the child at. @@ -48,7 +48,7 @@ const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib' function seedWorkspace( files: { workspace?: Record - personal?: Record + harnessHome?: Record skills?: Record }, ): (cwd: string) => Promise { @@ -58,7 +58,7 @@ function seedWorkspace( await mkdir(dirname(file), { recursive: true }) await writeFile(file, content) } - for (const [name, content] of Object.entries(files.personal ?? {})) { + for (const [name, content] of Object.entries(files.harnessHome ?? {})) { const file = join(cwd, '.dsh', name) await mkdir(dirname(file), { recursive: true }) await writeFile(file, content) @@ -652,7 +652,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('Preserve restored state') }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('boots the shipped default config with no arguments and no personal overlay', async () => { + it('boots the shipped default config with no arguments and no overlay', async () => { const output = await smoke({ label: 'dsh default boot', tempDirPrefix: 'dsh-default-boot-', @@ -667,9 +667,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('applies the personal overlay: config.yaml patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => { - // The whole personal-config chain in one boot, plus the environment - // layering underneath it. config.yaml patches the `tui` row — a row the + it('applies a --config overlay: it patches an overlay-inserted row, and both .env layers feed its !!js with the project one winning', async () => { + // The whole explicit-overlay chain in one boot, plus the environment + // layering underneath it. The named file patches the `tui` row — a row the // SURFACE OVERLAY inserted, not one the base declares — proving a later // patch list reaches a row an earlier one inserted. The `!!js` expression // renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is @@ -678,13 +678,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // arrive. Credentials are not part of this: they live in // `.credentials.yaml`, which is never hoisted into `process.env`. const output = await smoke({ - label: 'dsh personal overlay', - tempDirPrefix: 'dsh-personal-overlay-', + label: 'dsh explicit overlay', + tempDirPrefix: 'dsh-explicit-overlay-', binScript: dshBinScript, - configArgs: [], + configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' }, - personal: { + harnessHome: { '.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n', 'config.yaml': [ '- id: workspace-context', @@ -705,7 +705,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('loads a cached repository Plugin from personal config alone', async () => { + it('loads a cached repository Plugin from a --config overlay alone', async () => { const source = 'github:fixture/repository#fixed-ref' const specifier = `${source}&path:/.dsh-plugin` const key = createHash('sha256').update(specifier).digest('hex') @@ -717,12 +717,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // deliberate external pin of the durable on-disk format. const wrapper = await generatePreparedWrapper('config-only-fixture') const output = await smoke({ - label: 'dsh personal repository Plugin', - tempDirPrefix: 'dsh-personal-repository-plugin-', + label: 'dsh overlay repository Plugin', + tempDirPrefix: 'dsh-overlay-repository-plugin-', binScript: dshBinScript, - configArgs: [], + configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ - personal: { + harnessHome: { 'config.yaml': [ '- id: repository-plugins', " name: '@deepseek-ai/dsh-repository-plugin'", @@ -753,13 +753,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('fails loud instead of booting when the personal config.yaml is invalid', async () => { + it('fails loud instead of booting when a named --config overlay is invalid', async () => { const output = await smoke({ - label: 'dsh invalid personal config', - tempDirPrefix: 'dsh-invalid-personal-', + label: 'dsh invalid overlay', + tempDirPrefix: 'dsh-invalid-overlay-', binScript: dshBinScript, - configArgs: [], - prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }), + configArgs: ['--config', '.dsh/config.yaml'], + prepare: seedWorkspace({ harnessHome: { 'config.yaml': 'id: not-a-list\n' } }), expectedExitCode: 1, }) expect(output).toContain('must be a top-level YAML array of loader patch entries') @@ -793,18 +793,18 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) }, PTY_SMOKE_TEST_TIMEOUT_MS) - it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => { - // Loader patches replace a targeted `config` key wholesale, so a personal - // overlay repointing the model route drops every identity key the shipped + it('keeps resume working when a --config overlay replaces the whole agent-loop config', async () => { + // Loader patches replace a targeted `config` key wholesale, so an overlay + // repointing the model route drops every identity key the shipped // row declared. Launcher-owned identity makes that unreachable: agent-loop // applies the launcher's id over whatever route survives. const output = await smoke({ label: 'dsh overlay keeps resume', tempDirPrefix: 'dsh-overlay-resume-', binScript: dshBinScript, - configArgs: [], + configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ - personal: { + harnessHome: { 'config.yaml': [ '- id: workspace-context', ' disabled: true', diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 6d1265e9f3..525fc2f1d8 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: 6f656b573490a08ec893f4d14b487e6082015049 -config.zh.md: d4bb30023df46845ea720f3e6a45184479df0e72 +config.md: b1cf3a57b2fd16d4139f1a11a6cd85e54bb957b5 +config.zh.md: dad03d8232851678cfb6dc690f0bbd3f02380fc8 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 6f656b5734..b1cf3a57b2 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -50,7 +50,7 @@ Plugins load in file order. Place plugins that depend on services after the appl ## CLI overlays -The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before the Web launcher's CLI-flag patches. +The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies the optional `dsh --config ` overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without any shipped layer. Every booting surface takes both flags — `dsh -p`, `dsh web`, `dsh meta`, and `dsh upgrade` included — because naming a file is the only way to compose your own tree. A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index d4bb30023d..dad03d8232 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -50,7 +50,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 ## CLI 覆盖层 -TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web 启动器的命令行标志补丁之前添加覆盖。 +TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用可选的 `dsh --config ` 覆盖。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用任何已交付层。每个会启动的界面都接受这两个标志,包括 `dsh -p`、`dsh web`、`dsh meta` 和 `dsh upgrade`——因为点名一个文件是组合自己配置树的唯一途径。 补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index def44e65e3..41266f9194 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/mcp-memory/README.md -README.md: b5dd7ffc4ad248d38e108d9aa28c7c26e0c76913 -README.zh.md: 1249ae40bb344fc81836cb49d71dd5656457b1b3 +README.md: 6e4c68277a99b2ac739bdfb71e6c36dfbef44e86 +README.zh.md: 66efb05e1aa1d295f1712f5f31b93f98ba68eb8e diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index b5dd7ffc4a..6e4c68277a 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -42,7 +42,7 @@ dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. -To keep the selection in personal configuration, merge the chosen file's single `insert` patch into `$DSH_HOME/config.yaml` (normally `~/.dsh/config.yaml`). Do not copy over an existing file: it may already contain unrelated personal patches. +To keep the selection across runs, merge the chosen file's single `insert` patch into your own overlay and name it on every launch (`dsh --config ~/.dsh/mcp.yml`). Do not copy over an existing overlay: it may already contain unrelated patches. ## Provider setup diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 1249ae40bb..66efb05e1a 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -42,7 +42,7 @@ dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" 若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 -如果要把所选配置保存在个人配置中,请将对应文件中的单个 `insert` patch 合并到 `$DSH_HOME/config.yaml`(通常是 `~/.dsh/config.yaml`)。不要覆盖已有文件,其中可能已经包含无关的个人 patch。 +如果要跨多次运行保留所选配置,请把对应文件中的单个 `insert` patch 合并到你自己的覆盖文件里,并在每次启动时点名它(`dsh --config ~/.dsh/mcp.yml`)。不要覆盖已有的覆盖文件,其中可能已经包含无关的 patch。 ## 提供方设置 diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index 8cd641781f..b64a5ea9c9 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/cordis/repository-plugin/README.md -README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7 -README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797 +README.md: d523d0e6296fc060741b7bc8e843c1332ea1677f +README.zh.md: c3240bad3f292ecfaa51e62d93e59cbf1c69be7f diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index 0ba1ce86d9..d523d0e629 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -30,7 +30,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: ## Standalone app configuration -The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`): +The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in a `--config` overlay (`dsh --config ~/.dsh/plugins.yml`): ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plug Each source must use `github:owner/repository#`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. -The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). +Every surface reads the overlay once at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). ## Preparation diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index 2d9544166e..c3240bad3f 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -30,7 +30,7 @@ ## 独立应用配置 -已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation: +已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在一个 `--config` 覆盖文件中替换该配置项的配置(`dsh --config ~/.dsh/plugins.yml`),即可启用精确指定的 GitHub generation: ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ 每个源都必须采用 `github:owner/repository#`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 -TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 +每个界面都只在启动时读取该覆盖文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 ## 准备阶段 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index be3bb757a4..ef81d9a2fd 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 8636af748168f6d898d7b44da298636af3686001 -README.zh.md: 0d956a3f5734cd04694fb96a6c89468e99413ebc +README.md: 9b443cb0850ba989733aa2dadd587088b60a51c2 +README.zh.md: dffc9eb5205edb52d9b9a84d98d20af0b17469b0 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 8636af7481..9b443cb085 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,10 +13,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | -| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR | -| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer | +| `loadOverlayPatches(binName, file)` | Parse a required patch-list file (a surface overlay or a `--config` file); read or parse failures throw a labelled error | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin as the boot's root entry | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | @@ -30,17 +28,15 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. -## Personal config +## The Harness home -A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: +A developer's machine-local state lives outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves). What this package reads from it is one file: - **`.env`** — the user's ordinary environment layer, loaded by the `dsh` bin through `loadLayeredEnv` beneath the invoking directory's `.env` and the inherited environment. It is plain environment with plain environment reach, not a secret boundary: what the Harness owns and isolates lives in `.credentials.yaml`, which no surface hoists. A key placed in this file therefore still resolves — as a read-only `env` layer that shadows the stored one and blocks rotation from the TUI and the web page. -- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. -The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. - -Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. +There is no automatically discovered composition file. Loader overlays reach a surface only by being named: `dsh --config ` layers a patch list over the shipped tree and `dsh --config-replace ` boots one instead of it, on every booting surface. Keeping an overlay in `~/.dsh` is fine — it is a location, not a layer, and nothing loads it unless the launch names it ([rationale](../../../.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md)). +Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's own files can never leak into fixtures. ## Model Experience Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application; the one export that contributes model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. @@ -54,4 +50,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is directory-scoped and optional** — each layer is one named directory's `.env`, and a failure warns; neither helper searches parents or validates required variables. `loadLayeredEnv` fixes its two layers at the invoking directory and the Harness home, so a caller wanting different layers composes `loadEnv` itself. -- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. +- **Overlays are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so an override restates the base fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 0d956a3f57..dffc9eb520 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -13,10 +13,8 @@ | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | -| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | -| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件(surface overlay 或 `--config` 文件);读取或解析失败时抛出带标签的错误 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,作为本次启动的根配置项 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | @@ -30,17 +28,15 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 -## 个人配置 +## Harness home -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: +开发者的机器本地状态位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析)。本包从中读取的只有一个文件: - **`.env`**:用户的普通环境层,由 `dsh` bin 经 `loadLayeredEnv` 加载,位于调用目录的 `.env` 与继承环境之下。它是具有普通环境作用域的普通环境值,而不是密钥边界:由 Harness 拥有并隔离的东西放在 `.credentials.yaml` 里,后者不会被任何表层提升。因此放进本文件的密钥仍然可以解析——但会作为只读的 `env` 层遮蔽已存储的那一份,并阻断从 TUI 与 Web 页面轮换密钥。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 -TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 - -子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 +不存在会被自动发现的组合文件。Loader overlay 只有被点名才会抵达某个界面:`dsh --config ` 在已交付配置树上叠加一个 patch 列表,`dsh --config-replace ` 则用它取代整棵树,两者在每个会启动的界面上都可用。把 overlay 放在 `~/.dsh` 里没有问题——那只是一个位置,不是一层,启动时不点名就不会加载它([依据](../../../.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md))。 +子进程测试启动器会把 `DSH_HOME` 指向每个测试独立的目录,因此开发者自己的文件绝不会泄漏进 fixture。 ## 模型体验 模型通过此包加载的插件树间接受到影响;该树决定最终应用中的提示词、schema、消息和模型适配器。唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 @@ -54,4 +50,4 @@ TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPa - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境加载按目录划分且为可选操作**:每一层都是一个指定目录下的 `.env`,失败时发出警告;两个 helper 都不会搜索父目录,也不验证必需变量。`loadLayeredEnv` 的两层固定为调用目录与 Harness home,需要其他层次的调用方请自行组合 `loadEnv`。 -- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 +- **overlay 采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此覆盖必须重述需要保留的基础字段。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 94a7aa2c7d..78dff3eca8 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,14 +1,14 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env` files, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the - * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to + * explicit overlay patch lists a surface composes, expose the Harness-home path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot */ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' @@ -95,49 +95,15 @@ export function loadLayeredEnv( loadEnv(binName, home, warn) } -/** File inside the Harness home holding the personal loader overlay patches. */ -export const PERSONAL_CONFIG_FILENAME = 'config.yaml' - -const bootstrapIncludes = new WeakMap() - -// The include's YAML dialect (`!!js` scalars become expression nodes the -// Loader interpolates against each entry's context at mount time), imported -// from the include itself so patch parsing and config dumping can never drift -// from what the include mounts. Personal patches share it so they may -// reference `process.env`. -const personalPatchesSchema = entryListSchema - /** - * Load the optional personal overlay patches (`config.yaml` under the Harness - * home). The file is a top-level YAML array of loader patch entries - * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides - * and `insert` lists, with `!!js` expressions allowed. A missing file means - * "no personal overlay"; an unreadable, unparsable, or non-array file throws — - * a present personal config that cannot apply is a misconfiguration and must - * fail loud at boot, never be silently skipped. - * @param binName - the diagnostic prefix on the thrown error. - * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`). - * @returns the parsed patches, or `undefined` when the file does not exist. - */ -export function loadPersonalPatches( - binName: string, dir: string = resolveDshHome(), -): PatchOptions[] | undefined { - const file = join(dir, PERSONAL_CONFIG_FILENAME) - let content: string - try { - content = readFileSync(file, 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined - throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`) - } - return parsePatchList(binName, file, content, 'personal patches') -} - -/** - * Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a - * `--config ` overlay applied over the shared base. Same file format as - * {@link loadPersonalPatches}, but a missing file throws, because the caller - * named this file — its absence is a misconfiguration, not "no overlay". + * Load an overlay patch list: a surface overlay (`tui.cordis.yml`) or a + * `--config ` overlay applied over the shared base. The file is a + * top-level YAML array of loader patch entries (`@cordisjs/plugin-include`'s + * `PatchOptions`): id-targeted config overrides and `insert` lists, with + * `!!js` expressions allowed — the dialect is imported from the include + * itself, so patch parsing and config dumping can never drift from what the + * include mounts. A missing file throws, because the caller named this file: + * its absence is a misconfiguration, not "no overlay". * @param binName - the diagnostic prefix on the thrown error. * @param file - absolute path of the overlay file. * @returns the parsed patch list. @@ -149,37 +115,32 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } catch (error) { throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`) } - return parsePatchList(binName, file, content, 'overlay') + return parsePatchList(binName, file, content) } /** - * Parse one loader patch list: a top-level YAML array of - * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and - * `insert` lists, `!!js` expressions allowed). Every shape failure throws, - * because a patch file that cannot be applied at all is a misconfiguration; a - * single patch whose target row is absent stays a per-entry Loader warning, so - * one overlay shared across surfaces does not have to match every tree. + * Parse one loader patch list. Every shape failure throws, because a patch + * file that cannot be applied at all is a misconfiguration; a single patch + * whose target row is absent stays a per-entry Loader warning, so one overlay + * shared across surfaces does not have to match every tree. * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. - * @param label - what to call this list in errors (`personal patches`, `overlay`). * @returns the parsed patch list. */ -function parsePatchList( - binName: string, file: string, content: string, label: string, -): PatchOptions[] { +function parsePatchList(binName: string, file: string, content: string): PatchOptions[] { let parsed: unknown try { - parsed = yaml.load(content, { schema: personalPatchesSchema }) + parsed = yaml.load(content, { schema: entryListSchema }) } catch (error) { - throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) + throw new Error(`${binName}: failed to parse overlay ${file}: ${String(error)}`) } if (!Array.isArray(parsed)) { - throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`) + throw new Error(`${binName}: overlay ${file} must be a top-level YAML array of loader patch entries`) } parsed.forEach((entry, index) => { if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) + throw new Error(`${binName}: overlay entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) } }) return parsed as PatchOptions[] @@ -189,7 +150,7 @@ function parsePatchList( export interface ConfigDumpLayer { /** Source name shown in provenance comments (a file basename or path). */ label: string - /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */ + /** The layer's patches, from {@link loadOverlayPatches}. */ patches: PatchOptions[] } @@ -320,70 +281,11 @@ function groupedDump( return lines.join('\n') + '\n' } -/** Options for live personal-config reconciliation. */ -export interface PersonalPatchWatchOptions { - /** Diagnostic prefix used by {@link loadPersonalPatches}. */ - binName: string - /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */ - dir?: string - /** - * Compose the full patch list for a fresh personal-overlay generation — - * the same composition the app booted with, so a reload can interleave the - * new personal patches between app-owned layers (surface overlay below, - * profile/flag patches above). Identity when omitted: the personal overlay - * is the whole patch list. - */ - compose?: (personalPatches: PatchOptions[]) => PatchOptions[] -} - /** - * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include. - * @param ctx - settled app context containing the root Include and an active HMR service. - * @param options - diagnostic, Harness-home, and patch-composition inputs. - * @returns an asynchronous disposer after the exact-path watcher is ready. - * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. - */ -export async function watchPersonalPatches( - ctx: Context, - options: PersonalPatchWatchOptions, -): Promise<() => Promise> { - const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options - const hmr = ctx.get('hmr') - if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`) - const entry = bootstrapIncludes.get(ctx) - if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) - const filename = join(dir, PERSONAL_CONFIG_FILENAME) - const register = hmr.registerConfig(filename, async () => { - // Re-read the include's non-patch options per refresh: a writer that - // updates the root Include's other options between refreshes (none exists - // today) must not have them silently reverted by a personal reload. - const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config - const personalPatches = loadPersonalPatches(binName, dir) ?? [] - const patches = compose(personalPatches) - await entry.update({ - config: { - ...includeConfig, - patches, - }, - }) - }) - try { - return await register - } catch (error) { - // A surface can dispose the whole tree while the watcher is still opening - // (a TUI `/exit` typed during startup): the HMR effect registration then - // fails with INACTIVE_EFFECT. That is the app exiting exactly as asked, - // not a watch failure — return a no-op disposer instead of crashing. - if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} - throw error - } -} - -/** - * Mount and remember the exact root Include entry used by app boot and personal-config HMR. + * Mount the root Include entry app boot drives. * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. - * @param patches - initial app and personal patches, applied in order. + * @param patches - the surface's overlay patches, applied in order. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. @@ -408,9 +310,7 @@ export async function mountRootInclude( const includeId = await ctx.loader.create(rootInclude) const loader = ctx.get('loader') if (loader === undefined) return undefined - const entry = loader.resolve(includeId) - bootstrapIncludes.set(ctx, entry) - return entry + return loader.resolve(includeId) } /** @@ -629,7 +529,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree - * (see {@link loadPersonalPatches}); an empty list mounts none. + * (see {@link loadOverlayPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. diff --git a/packages/ui/app-boot/tests/config-dump.spec.ts b/packages/ui/app-boot/tests/config-dump.spec.ts index 99af81f2c2..4f5d8e83e5 100644 --- a/packages/ui/app-boot/tests/config-dump.spec.ts +++ b/packages/ui/app-boot/tests/config-dump.spec.ts @@ -49,17 +49,17 @@ describe('renderConfigDump', () => { ' name: ./noop.mjs', '', ].join('\n')) - const personal = join(dir, 'personal.yml') - writeFileSync(personal, [ + const user = join(dir, 'user.yml') + writeFileSync(user, [ '- id: surface-extra', ' config:', - ' value: personal', + ' value: user', '', ].join('\n')) const dump = renderConfigDump(NAME, base, [ { label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) }, - { label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) }, + { label: 'user.yml', patches: loadOverlayPatches(NAME, user) }, ], () => {}) // Comments do not break loadability: the dump parses as one document // equal to what boot() would mount. @@ -74,7 +74,7 @@ describe('renderConfigDump', () => { config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } }, }, { id: 'untouched', name: './noop.mjs' }, - { id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } }, + { id: 'surface-extra', name: './noop.mjs', config: { value: 'user' } }, ]) // Unevaluated: the expression text round-trips as a !!js scalar. expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC') @@ -82,7 +82,7 @@ describe('renderConfigDump', () => { // row; an inserted row carries the inserting layer as its origin. expect(dump).toContain('# == base.yml, patched by surface.yml') expect(dump).toContain('# == base.yml\n- id: untouched') - expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra') + expect(dump).toContain('# == surface.yml, patched by user.yml\n- id: surface-extra') expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched')) }) diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index d9f4ffa830..81ba2fc845 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -341,12 +341,12 @@ describe('include refresh with overlay patches', () => { describe('include patches layered over one base', () => { it('lets a later patch configure or disable a row an earlier patch inserted', async () => { - // The surface/`--config`/personal composition: `dsh` includes one shared - // base and applies each source as its own patch list at the SAME include - // level, because patches never cross an include boundary. A later layer - // must therefore be able to reach a row an earlier layer inserted — - // otherwise every surface-only row (the whole TUI front door) would be - // invisible to the user's `~/.dsh/config.yaml`. + // The surface/`--config` composition: `dsh` includes one shared base and + // applies each source as its own patch list at the SAME include level, + // because patches never cross an include boundary. A later layer must + // therefore be able to reach a row an earlier layer inserted — otherwise + // every surface-only row (the whole TUI front door) would be invisible to + // the user's `--config` overlay. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') @@ -370,7 +370,7 @@ describe('include patches layered over one base', () => { // Layer 2 (the user): reconfigure one inserted row and disable the other. ' - id: surface-kept', ' config:', - ' value: personal', + ' value: user', ' - id: surface-dropped', ' disabled: true', '', @@ -378,7 +378,7 @@ describe('include patches layered over one base', () => { const ctx = await boot(NAME, join(dir, 'cordis.yml')) try { expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' }) - expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' }) + expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'user' }) const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped') expect(dropped?.options.disabled).toBe(true) expect(dropped?.fiber).toBeUndefined() diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/personal-config.spec.ts deleted file mode 100644 index 53df1d84b7..0000000000 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`) - * `config.yaml` overlay loader and `boot()` applying the personal overlay over - * a real Loader tree. - */ - -import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Hmr from '@cordisjs/plugin-hmr' -import Loader from '@cordisjs/plugin-loader' -import Timer from '@cordisjs/plugin-timer' -import { - boot, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, - watchPersonalPatches, -} from '../src/index.ts' - -const NAME = 'dsh-test-bin' - -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) - -async function eventually(test: () => boolean, message: string): Promise { - const deadline = Date.now() + 10_000 - while (!test()) { - if (Date.now() >= deadline) throw new Error(message) - await new Promise(resolve => setTimeout(resolve, 10)) - } -} - -const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) - -describe('loadPersonalPatches', () => { - afterEach(() => { - delete process.env.DSH_HOME - }) - - it('returns undefined when no personal patches file exists', () => { - expect(loadPersonalPatches(NAME, tmp())).toBeUndefined() - }) - - it('parses a patch list and preserves !!js expressions as loader expression nodes', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", - ' config:', - ' model: !!js process.env.DSH_SPEC_MODEL', - '- insert:', - ' - id: llm', - " name: '@deepseek-ai/dsh-llm-pi-ai'", - '', - ].join('\n')) - const patches = loadPersonalPatches(NAME, dir) - expect(patches).toHaveLength(2) - expect(patches?.[0]).toMatchObject({ - id: 'tui-agent', - config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } }, - }) - expect(patches?.[1]?.insert).toHaveLength(1) - }) - - it('defaults its directory to the Harness home ($DSH_HOME)', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n') - process.env.DSH_HOME = dir - expect(loadPersonalPatches(NAME)).toHaveLength(1) - }) - - it('fails loud on an unreadable file (a present personal config is never skipped)', () => { - const dir = tmp() - mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to read personal patches `)) - }) - - it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) - }) - - it('fails loud when the file is not a top-level array or an entry is not an object', () => { - const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow('must be a top-level YAML array of loader patch entries') - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(`${NAME}: personal patches entry 1 in`) - }) -}) - -describe('boot with personal patches', () => { - function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), [ - 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', - ' if (config.fail) throw new Error("candidate config failed")', - '}', - '', - ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - return join(dir, 'cordis.yml') - } - - function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config - } - - it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { - const dir = tmp() - const personal = tmp() - writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [ - '- id: noop', - ' name: ./noop.mjs', - ' config:', - ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC', - '- insert:', - ' - id: personal-extra', - ' name: ./noop.mjs', - '', - ].join('\n')) - process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value' - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal)) - try { - const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') - // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'personal-value' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true) - } finally { - await ctx.fiber.dispose() - delete process.env['DSH_APP_BOOT_PERSONAL_SPEC'] - } - }) - - it('mounts no patch layer for an absent or empty personal overlay', async () => { - const dir = tmp() - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp())) - try { - expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' }) - } finally { - await ctx.fiber.dispose() - } - const empty = tmp() - writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n') - const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty)) - try { - expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' }) - } finally { - await ctxEmpty.fiber.dispose() - } - }) - - it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { - const dir = tmp() - const personal = tmp() - const filename = join(personal, PERSONAL_CONFIG_FILENAME) - const basePatches = [{ id: 'noop', config: { value: 'generated' } }] - const ctx = await boot(NAME, writeTree(dir), basePatches) - await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - const failures: Array<{ filename: string; error: Error }> = [] - ctx.on('hmr/config-update-failed', (failedFilename, error) => { - failures.push({ filename: failedFilename, error }) - }) - const dispose = await watchPersonalPatches(ctx, { - binName: NAME, - dir: personal, - compose: personalPatches => [...basePatches, ...personalPatches], - }) - try { - writeFileSync(filename, '- id: noop\n config:\n value: live\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') - - writeFileSync(filename, '- id: noop\n config:\n fail: true\n') - await eventually(() => failures.length === 1, 'failed candidate was not broadcast') - expect(failures[0]).toMatchObject({ filename }) - expect(failures[0]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') - await settleChokidarChangeThrottle() - - writeFileSync(filename, 'invalid: [unclosed\n') - await eventually(() => failures.length === 2, 'parse failure was not broadcast') - expect(failures[1]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') - await settleChokidarChangeThrottle() - - writeFileSync(filename, '- id: noop\n config:\n value: recovered\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied') - await settleChokidarChangeThrottle() - - unlinkSync(filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') - expect(failures).toHaveLength(2) - await settleChokidarChangeThrottle() - - // Default compose: the personal overlay IS the whole patch list, so a - // fresh generation replaces the app-owned layer instead of stacking on it. - await dispose() - const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) - try { - writeFileSync(filename, '- id: noop\n config:\n value: identity\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') - } finally { - await disposeDefault() - } - } finally { - await dispose() - await ctx.fiber.dispose() - } - }) - - it('fails loud when the exact watcher lacks HMR or a root Include', async () => { - const dir = tmp() - const withoutHmr = await boot(NAME, writeTree(dir)) - await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service') - await withoutHmr.fiber.dispose() - - const withoutInclude = new Context() - withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href - await withoutInclude.plugin(Loader) - await withoutInclude.plugin(Timer) - await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') - await withoutInclude.fiber.dispose() - }) - - it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { - // A TUI `/exit` typed during startup disposes the whole tree while - // registerConfig's effect registration is still in flight (the HMR effect - // then fails with INACTIVE_EFFECT); the app is exiting exactly as asked, - // so the watcher must not crash the process. The stub makes the race - // deterministic — the live-teardown ordering itself is not stageable. - const dir = tmp() - const ctx = await boot(NAME, writeTree(dir)) - try { - const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) - ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() }) - await expect(dispose()).resolves.toBeUndefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('propagates registration failures other than mid-teardown', async () => { - const dir = tmp() - const personal = tmp() - const ctx = await boot(NAME, writeTree(dir)) - try { - await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) - // Same personal path registered twice: HMR refuses; not a teardown race. - await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered') - await dispose() - } finally { - await ctx.fiber.dispose() - } - }) -}) From 0512b12714634ffcdddef1df741e34c2fed53cb7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:17:32 +0800 Subject: [PATCH 04/88] feat(config)!: one ordering for configuration sources, and a bootstrap deny rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $DSH_HOME/.env had just become an ordinary environment layer, which left the harness resolving user-facing values from a flattened process.env that could no longer say where a value came from. A key stored through the web page stayed shadowed by an older key in the user's own .env. An endpoint could be redirected by the project: the invoking directory's .env is materialized like every other layer, and a base URL decides where a resolved API key is sent, so a DEEPSEEK_BASE_URL written into a model-editable workspace would send the user's credential — and the prompts carrying their code — to whatever host that file named. Give every user-facing value one ordering, with four kinds of source: explicit for this run per-operation override, CLI argument > authored by deployment --config / --config-replace > this launch's shell inherited process environment > product-managed store settings.yaml, .credentials.yaml > discovered file $DSH_HOME/.env > defaults schema default, shipped base, public default The domains differ only in which tiers exist. The earlier split — credentials ranking the environment over the managed file while settings ranked over the environment — was inconsistent: the distinguishing fact is who authored the source, not the domain. packages/util/environment owns an immutable snapshot with per-layer provenance. getFrom(name, sources) searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for ['process', 'user-env'], so no reordering can let a project file back into a decision it was excluded from. isBootstrapOnly rejects, before anything is materialized, any .env setting a variable that governs how a process launches (PATH, SHELL, NODE_OPTIONS, LD_PRELOAD), where code or model-visible instructions load from (the whole DSH_* namespace, HOME, XDG_*), or how the network is reached (proxy and CA variables). The namespace is denied wholesale so a switch added later cannot become settable by being forgotten, and there is no opt-out. verify-config-source-ownership keeps both rules: no unregistered process.env read under packages/*/*/src (26 allowlisted with reasons), and no apiKey, baseURL, or headers inlined from the environment in shipped Cordis config — removing those inlines is what makes the deployment tier meaningful. --- ...4-configuration-source-ownership.i18n.yaml | 6 + ...26-08-04-configuration-source-ownership.md | 63 +++++++ ...08-04-configuration-source-ownership.zh.md | 65 +++++++ THIRD_PARTY_NOTICES.md | 1 + apps/cli/config/base.cordis.yml | 1 - apps/cli/config/tui.cordis.yml | 2 - apps/cli/config/web.cordis.yml | 5 - apps/cli/package.json | 3 +- apps/cli/src/app-cli-entry.ts | 6 + apps/cli/src/bin.ts | 15 +- apps/cli/src/headless.ts | 7 +- apps/cli/src/tui.ts | 5 + apps/cli/src/web.ts | 4 + apps/cli/tests/tui-keyless-smoke.e2e.ts | 12 +- apps/cli/tsconfig.json | 3 + docs/config-catalog.md | 13 +- examples/acp-agent/cordis.yml | 2 - examples/acp-agent/retry.cordis.yml | 2 - examples/jsonrpc-agent/cordis.yml | 2 - .../jsonrpc-agent/persistent-tools.cordis.yml | 2 - package.json | 157 +++++++-------- .../credentials-local/package.json | 2 + .../credentials-local/src/index.ts | 75 ++++++-- .../credentials-local/tests/local.spec.ts | 69 +++++++ .../credentials-local/tsconfig.json | 3 + packages/llm/llm-deepseek/package.json | 2 + packages/llm/llm-deepseek/src/index.ts | 28 ++- .../llm/llm-deepseek/tests/adapter.spec.ts | 22 ++- packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/package.json | 2 + packages/llm/llm-pi-ai/src/index.ts | 7 +- packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/ui/app-boot/package.json | 3 + packages/ui/app-boot/src/index.ts | 76 +++++++- packages/ui/app-boot/tests/app-boot.spec.ts | 64 ++++++- packages/ui/app-boot/tsconfig.json | 3 + packages/util/environment/README.i18n.yaml | 6 + packages/util/environment/README.md | 42 +++++ packages/util/environment/README.zh.md | 42 +++++ packages/util/environment/package.json | 37 ++++ packages/util/environment/src/index.ts | 178 ++++++++++++++++++ packages/util/environment/src/invariant.ts | 30 +++ .../environment/tests/environment.spec.ts | 118 ++++++++++++ packages/util/environment/tsconfig.json | 15 ++ packages/web/web-search-deepseek/package.json | 2 + packages/web/web-search-deepseek/src/index.ts | 7 +- .../web/web-search-deepseek/tsconfig.json | 3 + packages/web/web-search-exa/package.json | 2 + packages/web/web-search-exa/src/index.ts | 6 +- packages/web/web-search-exa/tsconfig.json | 3 + .../web/web-search-perplexity/package.json | 2 + .../web/web-search-perplexity/src/index.ts | 6 +- .../web/web-search-perplexity/tsconfig.json | 3 + pnpm-lock.yaml | 45 +++++ python/sdk-runtime/package.json | 1 + scripts/run-gates.ts | 1 + scripts/verify-config-source-ownership.ts | 117 ++++++++++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 59 files changed, 1241 insertions(+), 165 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md create mode 100644 .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md create mode 100644 packages/util/environment/README.i18n.yaml create mode 100644 packages/util/environment/README.md create mode 100644 packages/util/environment/README.zh.md create mode 100644 packages/util/environment/package.json create mode 100644 packages/util/environment/src/index.ts create mode 100644 packages/util/environment/src/invariant.ts create mode 100644 packages/util/environment/tests/environment.spec.ts create mode 100644 packages/util/environment/tsconfig.json create mode 100644 scripts/verify-config-source-ownership.ts diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml new file mode 100644 index 0000000000..7ff8cfa74c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +2026-08-04-configuration-source-ownership.md: f19067abb899e41742f88ce6d17623bc5b82d008 +2026-08-04-configuration-source-ownership.zh.md: a5fd7c61ee71eb9ed9184c3f9c557fb1c3b951ad diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md new file mode 100644 index 0000000000..f19067abb8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -0,0 +1,63 @@ +# Agent Note: One ordering for configuration sources, and what a discovered file may not decide + +Status: implemented + +English | [中文](2026-08-04-configuration-source-ownership.zh.md) + +## Problem + +`$DSH_HOME/.env` had just [become an ordinary environment layer](2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed. + +A key stored through the web page stayed shadowed by an older key in the user's own `.env`, because the credential provider compared "the environment" against its file and the environment now included that file. The migration dead end the split was supposed to remove had simply moved. + +An endpoint could be redirected by the project. The invoking directory's `.env` is materialized like every other layer, and a base URL decides where a resolved API key is sent — so a `DEEPSEEK_BASE_URL` written into a workspace the model can edit would send the user's own credential, and the prompts carrying their code, to whatever host that file named. Nothing about the flattened view could distinguish that from the operator exporting the same variable. + +And `!!js process.env.X` in the shipped composition made the same value reachable twice: once through the entry config and once through whatever ladder its consumer applied, with the winner decided by layer order rather than by what the value means. + +## Decision + +**One ordering, four kinds of source.** Every user-facing value resolves in the same order; the domains differ only in which tiers exist. + +```text +explicit for this run per-operation override, CLI argument +> authored by deployment --config / --config-replace +> this launch's shell inherited process environment +> product-managed store settings.yaml, .credentials.yaml +> discovered file $DSH_HOME/.env +> defaults schema default, shipped base, provider public default +``` + +Credentials have no deployment tier (configuration carries a reference, never a value) and no default. Endpoints have every tier. Model selection has CLI, settings, and the shipped default. The earlier proposal ranked a UI-written credential *below* the environment while ranking UI-written settings *above* it; the distinguishing fact is not the domain but who authored the file, so `.credentials.yaml` and `settings.yaml` now sit together, both under the launching shell and both over a discovered `.env`. + +**The invoking directory's `.env` decides no credential and no route.** `EnvironmentSnapshot.getFrom(name, sources)` searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for `['process', 'user-env']`, so no future reordering can let a project file back into a decision it was excluded from. A project `.env` remains an ordinary environment layer for ordinary variables. + +**A discovered file may not decide how the process starts.** `isBootstrapOnly` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`, …), where code or model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. + +The whole `DSH_*` namespace is denied rather than an audited subset. The harness's own switches — the permission mode, the agents home that holds model-visible skills, the bundled skill root — are exactly what a hostile project would reach for, and a switch added later must not become settable by being forgotten. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. + +**`packages/util/environment` owns the snapshot**, deliberately as a utility rather than a three-package capability seam. The snapshot is frozen before Cordis starts and injected once by the launcher, so there is no runtime implementation to swap; consumers need types and pure functions, which a `util/` package gives them without depending on a UI package. `environmentOf(ctx)` returns the launcher's snapshot, or the inherited environment as the only layer — an SDK host or bare `cordis.yml` discovered no files, so its single layer really is what it was launched with, and the same trusted lookups keep working there unchanged. + +**`verify-config-source-ownership`** keeps both rules: no unregistered `process.env` read under `packages/*/*/src` (26 allowlisted, each with the reason it is a process fact), and no `apiKey`/`baseURL`/`headers` inlined from the environment in shipped Cordis configuration. Removing those inlines is what makes the deployment tier meaningful — with the shipped tree silent on `baseURL`, a present value means a human or deployment set it. + +## Consequences + +- The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. +- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- `--config` is no longer overridable by a stale shell endpoint, so a deployment can pin an enterprise gateway. +- Given up: an endpoint or key in the invoking directory's `.env` no longer applies. Per-project routing is a `--config` overlay or an `export` in that project's shell. +- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. +- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. + +## Alternatives considered + +**Keep the proposal's split ladders (credentials env-over-file, endpoints settings-over-env).** Rejected on its own inconsistency: both arguments — "an export is this run's intent" and "a deployment's file should not be rewritten by a stale shell" — apply to both domains. Sorting by *who authored the source* explains both and produces one table instead of four. + +**Let the invoking directory's `.env` supply a credential, ranked below the managed store.** Rejected: with no key stored, a hostile project's key would be used silently, and the account holder reads every prompt sent under it. That is the same exfiltration the endpoint rule exists to prevent, so it takes the same answer. + +**Audit an allowlist of `DSH_*` variables a `.env` may set.** Rejected: the list would have to be re-audited on every new switch, and the failure mode of forgetting is silent. Denying the namespace fails safe. + +**Rank a bootstrap variable below the process layer instead of rejecting it.** Rejected: `PATH` and `NODE_OPTIONS` have no meaningful "loser" behavior — a user who put one in a `.env` believes it applies, and silently ignoring it is the "my setting has no effect" failure this whole series exists to remove. + +**Build the snapshot as a three-package capability seam (`environment` / `environment-local` / consumers).** Rejected as premature: the producer runs before Cordis exists and there is no second implementation to select. The repository rule is to not split preemptively. + +**Stop materializing the layers into `process.env`.** Deferred, not rejected: it would keep project variables out of child processes entirely, but it silently breaks any user `--config` tree that reads `!!js process.env.X`. The snapshot is already the authority for everything the harness resolves, so this can land later without changing any ladder. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md new file mode 100644 index 0000000000..a5fd7c61ee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 配置来源的统一顺序,以及被发现的文件不得决定什么 + +Status: implemented + +[English](2026-08-04-configuration-source-ownership.md) | 中文 + +## Problem + +`$DSH_HOME/.env` 刚刚[变成普通环境层](2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。 + +通过 Web 页面存下的密钥仍然被用户自己 `.env` 里更旧的密钥遮蔽,因为凭据 provider 是拿「环境」与自己的文件比较,而现在环境包含了那个文件。这次拆分本该消除的迁移死路,只是换了个位置。 + +endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会被物化,而 base URL 决定已解析的 API key 发往何处——于是写进模型可编辑工作区的 `DEEPSEEK_BASE_URL`,会把用户自己的凭据、以及承载其代码的提示词,一起发给该文件指定的任何主机。压平的视图无法把这件事和运维显式 export 同一个变量区分开。 + +而已交付组合里的 `!!js process.env.X` 让同一个值有两条抵达路径:一条经 entry config,一条经消费方各自的 ladder,胜负取决于层序而非这个值的语义。 + +## Decision + +**一条顺序,四类来源。** 每个面向用户的值按同一顺序解析;各领域的差别只在于哪些层存在。 + +```text +explicit for this run per-operation override, CLI argument +> authored by deployment --config / --config-replace +> this launch's shell inherited process environment +> product-managed store settings.yaml, .credentials.yaml +> discovered file $DSH_HOME/.env +> defaults schema default, shipped base, provider public default +``` + +自上而下依次是:本次运行的显式意图、部署授权、本次启动的 shell、产品受管存储、被发现的文件、默认值。 + +凭据没有部署层(配置携带引用,从不携带值),也没有默认值层。endpoint 拥有全部层。模型选择只有 CLI、settings 与已交付默认值。此前的方案把 UI 写入的凭据排在环境*之下*,却把 UI 写入的 settings 排在环境*之上*;真正的区分依据不是领域,而是这个文件由谁书写,因此 `.credentials.yaml` 与 `settings.yaml` 现在并列,同在启动 shell 之下、同在被发现的 `.env` 之上。 + +**调用目录的 `.env` 不决定任何凭据与路由。** `EnvironmentSnapshot.getFrom(name, sources)` 只搜索调用方点名的层,省略某层是拒绝而不是降级:适配器请求的是 `['process', 'user-env']`,因此后续任何重新排序都无法让项目文件重新进入一个它被排除在外的决策。对普通变量而言,项目 `.env` 仍然是普通环境层。 + +**被发现的文件不得决定进程如何启动。** `isBootstrapOnly` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD` 等)、决定代码或模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何抵达与信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +被拒绝的是整个 `DSH_*` 命名空间,而不是一份经过审查的子集。harness 自己的开关——权限模式、存放模型可见 skill(技能)的 agents home、内置 skill 根目录——恰恰是敌意项目最想伸手的地方,而后来新增的开关不能因为被遗忘就变得可设置。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 + +**`packages/util/environment` 拥有该快照**,刻意做成 utility 而不是三包能力 seam。快照在 Cordis 启动前就冻结,并由启动器一次性注入,因此不存在需要切换的运行时实现;消费方需要的只是类型和纯函数,而 `util/` 包能提供这些且不必依赖 UI 包。`environmentOf(ctx)` 返回启动器的快照,或者返回只含继承环境的那一层——SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,它那唯一一层确实就是它被启动时的环境,因此同样的受信查询在那里原样继续工作。 + +**`verify-config-source-ownership`** 守住这两条规则:`packages/*/*/src` 下没有未登记的 `process.env` 读取(26 处在 allowlist 中,各自写明它为何是进程事实),以及已交付 Cordis 配置中不得从环境内联 `apiKey`/`baseURL`/`headers`。删除这些内联正是「部署层」得以成立的原因——已交付配置树对 `baseURL` 保持沉默之后,「有值」就意味着「人或部署设过它」。 + +## Consequences + +- Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 +- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- `--config` 不再会被陈旧的 shell endpoint 覆盖,因此部署方可以钉住企业网关。 +- 放弃的:调用目录 `.env` 里的 endpoint 或密钥不再生效。按项目切换路由请用 `--config` overlay 或该项目 shell 里的 `export`。 +- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 +- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 + +## Alternatives considered + +**沿用方案里分开的两条 ladder(凭据环境压过文件、endpoint settings 压过环境)。** 因其自身的不自洽而否决:两条理由——「export 是本次运行的意图」和「部署方的文件不该被陈旧 shell 改写」——对两个领域同样成立。按*来源由谁书写*排序能同时解释两者,并且把四张表变成一张。 + +**允许调用目录 `.env` 提供凭据,排在受管存储之下。** 否决:在没有存储密钥时,敌意项目的密钥会被静默使用,而该账号持有者能读到以它发出的每一条提示词。这与 endpoint 规则要防的外泄是同一件事,因此答案也相同。 + +**审查出一份 `.env` 可设置的 `DSH_*` 白名单。** 否决:每新增一个开关都要重新审查,而遗漏的失败模式是静默的。拒绝整个命名空间是 fail safe。 + +**把 bootstrap 变量排在 process 层之下,而不是拒绝它。** 否决:`PATH` 和 `NODE_OPTIONS` 没有有意义的「输了之后」行为——把它写进 `.env` 的用户认为它生效,而静默忽略正是整个系列要消除的那种「我的设置没有效果」。 + +**把快照做成三包能力 seam(`environment` / `environment-local` / 消费方)。** 作为过早拆分而否决:生产方在 Cordis 存在之前就运行,也没有第二个实现需要选择。仓库规则是不要预先拆分。 + +**不再把各层物化进 `process.env`。** 延后而非否决:它能让项目变量彻底进不了子进程,但会静默破坏任何读 `!!js process.env.X` 的用户 `--config` 树。快照已经是 harness 解析一切的依据,因此这件事以后落地也不改变任何 ladder。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 515004086e..92ea0d2406 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,6 +52,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index aea2f8934c..9985e8fbd0 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -360,7 +360,6 @@ name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL - id: tool-web name: '@deepseek-ai/dsh-tool-web' diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index 02d8649447..a3118a5419 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -40,8 +40,6 @@ # resolution materializes request defaults before the request header is logged. - id: llm-deepseek config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index efd2f93b2a..7dc72708c3 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -36,11 +36,6 @@ # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE -- id: llm-deepseek - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dshClient` rows are the browser roster the modules node half scans into diff --git a/apps/cli/package.json b/apps/cli/package.json index 8c482ac3e2..7d67e704b9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -74,9 +75,9 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 95776484d0..6b072e8aeb 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -14,6 +14,7 @@ import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { resolve } from 'node:path' import { Context } from 'cordis' +import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { boot, installFailLoud, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' @@ -102,6 +103,8 @@ const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { + /** This run's frozen environment, provided to the tree before any config entry mounts. */ + environment: EnvironmentSnapshot /** Absolute path of the shared base config the Loader includes. */ configPath: string /** @@ -255,6 +258,9 @@ export class AppCLIEntry { ...this.patches, ] this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => { + // Before any config-tree entry mounts, so a plugin that resolves a + // user-facing value at construction already sees this run's layers. + ctx.provide(DSH_ENVIRONMENT_KEY, this.options.environment) await this.options.prepare?.(ctx) if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) }) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index bdef3205b9..ae00d6b168 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -24,24 +24,27 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadLayeredEnv('dsh') +const environment = loadLayeredEnv('dsh') // The env opt-in is read at the process boundary; `1` is the documented value. const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1') switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) + await runWeb( + environment, invocation.host, invocation.port, invocation.dev, + invocation.workspaceRoot, invocation.trustedHosts, invocation.config, + ) break } case 'headless': { const { runHeadless } = await import('./headless.ts') - await runHeadless(invocation.prompt, invocation.config, invocation.configReplace) + await runHeadless(environment, invocation.prompt, invocation.config, invocation.configReplace) break } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) + await runTui(environment, invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) break } case 'dump-config': { @@ -51,12 +54,12 @@ switch (invocation.mode) { } case 'meta': { const { runTui, SOURCE_ROOT } = await import('./tui.ts') - await runTui(invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace) + await runTui(environment, invocation.config, undefined, SOURCE_ROOT, undefined, invocation.configReplace) break } case 'upgrade': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace) + await runTui(environment, invocation.config, undefined, undefined, `dsh-${invocation.mode}`, invocation.configReplace) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 5864604e05..098992f181 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url' import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -71,15 +72,19 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, * Run one headless turn for `task` and exit (completed → 0, else 1). The task * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` * (the adapter rejects an empty task, so no guard is needed here). + * @param environment - this run's frozen environment snapshot. * @param task - the prompt text for the single turn. * @param config - a `--config` overlay applied over the shipped composition, or `undefined`. * @param configReplace - a `--config-replace` tree booted instead of the * shipped composition, or `undefined`. It must mount a webserver row: this * surface reaches its own agent over the same HTTP gateway the browser uses. */ -export async function runHeadless(task: string, config?: string, configReplace?: string): Promise { +export async function runHeadless( + environment: EnvironmentSnapshot, task: string, config?: string, configReplace?: string, +): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ + environment, configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 20981dc068..6d36b0faa8 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -29,6 +29,7 @@ import { resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { PatchOptions } from '@cordisjs/plugin-include' import { SessionId } from '@deepseek-ai/dsh-session' import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts' @@ -78,6 +79,8 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) the CLI PTY smoke drives this path end to end, --config overlay included */ /** * Run the interactive TUI from the invoking directory. + * @param environment - this run's frozen environment snapshot, provided to the + * tree before any config entry mounts. * @param config - an overlay patch list applied over the shared base and the * TUI overlay, or `undefined` for the shipped composition alone; already * parsed from `--config`. @@ -97,6 +100,7 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * already parsed from `--config-replace`. */ export async function runTui( + environment: EnvironmentSnapshot, config: string | undefined, resumeSessionId: string | undefined, workspace?: string, @@ -225,6 +229,7 @@ export async function runTui( // Runs after the Loader installs and before any config-tree entry mounts, // so the fail-loud release hook can reach the tree for the whole window in // which an entry may reject. + hostCtx.provide(DSH_ENVIRONMENT_KEY, environment) app.current = hostCtx // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index a3dc446706..fcab06f54b 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -12,6 +12,7 @@ import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tool-bash' +import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { AppCLIEntry } from './app-cli-entry.ts' // The shared core every `dsh` surface mounts, plus this surface's overlay over it. @@ -85,6 +86,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed * through only when the flag was given; absent, the shipped Web overlay value stands. + * @param environment - this run's frozen environment snapshot. * @param host - the bind host, or `undefined` to keep the config default. * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles. @@ -95,6 +97,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: * personal overlay; already parsed from `--config`. */ export async function runWeb( + environment: EnvironmentSnapshot, host: string | undefined, port: number | undefined, dev: boolean, @@ -104,6 +107,7 @@ export async function runWeb( ): Promise { const mode: WebMode = dev ? 'development' : 'production' const entry = new AppCLIEntry({ + environment, configPath: BASE_CONFIG, overlayPath: WEB_OVERLAY, ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 2894d0a1ca..94366f4c61 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -672,9 +672,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // layering underneath it. The named file patches the `tui` row — a row the // SURFACE OVERLAY inserted, not one the base declares — proving a later // patch list reaches a row an earlier one inserted. The `!!js` expression - // renders both halves of the layering in one line: `DSH_LAYER_WELCOME` is + // renders both halves of the layering in one line: `OVERLAY_LAYER_WELCOME` is // set by BOTH .env files and must render the project value, while - // `DSH_USER_ONLY` exists only in the harness home's .env and must still + // `OVERLAY_USER_ONLY` exists only in the harness home's .env and must still // arrive. Credentials are not part of this: they live in // `.credentials.yaml`, which is never hoisted into `process.env`. const output = await smoke({ @@ -683,17 +683,17 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { binScript: dshBinScript, configArgs: ['--config', '.dsh/config.yaml'], prepare: seedWorkspace({ - workspace: { '.env': 'DSH_LAYER_WELCOME=PROJECT WINS.\n' }, + workspace: { '.env': 'OVERLAY_LAYER_WELCOME=PROJECT WINS.\n' }, harnessHome: { - '.env': 'DSH_LAYER_WELCOME=USER LAYER LOST.\nDSH_USER_ONLY=USER LAYER LOADED.\n', + '.env': 'OVERLAY_LAYER_WELCOME=USER LAYER LOST.\nOVERLAY_USER_ONLY=USER LAYER LOADED.\n', 'config.yaml': [ '- id: workspace-context', ' disabled: true', '- id: tui', ' config:', " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", - ' welcome: !!js "(process.env.DSH_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')' - + ' + \' \' + (process.env.DSH_USER_ONLY ?? \'USER LAYER MISSING.\')"', + ' welcome: !!js "(process.env.OVERLAY_LAYER_WELCOME ?? \'PROJECT LAYER MISSING.\')' + + ' + \' \' + (process.env.OVERLAY_USER_ONLY ?? \'USER LAYER MISSING.\')"', '', ].join('\n'), }, diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 2f995abf87..77da8d2bff 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../packages/ui/tui" }, + { + "path": "../../packages/util/environment" + }, { "path": "../../packages/util/paths" }, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ab0ca22024..44888f182a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -423,7 +423,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:35`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:54`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` @@ -632,7 +632,7 @@ export interface Config { apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ + /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' @@ -665,7 +665,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:60`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:61`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -2229,7 +2229,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts) +Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` @@ -2251,7 +2251,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-exa/src/index.ts:37`](../packages/web/web-search-exa/src/index.ts) +Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts) ## `@deepseek-ai/dsh-web-search-perplexity` @@ -2273,7 +2273,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-perplexity/src/index.ts:31`](../packages/web/web-search-perplexity/src/index.ts) +Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts) ## `@deepseek-ai/dsh-workflow-workerthread` @@ -2417,6 +2417,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 6edcee5cd8..8aaf690002 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -9,8 +9,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index 589120c080..087faa271d 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -13,8 +13,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max retryPolicy: diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 9806413725..9e9d908593 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -12,8 +12,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml index b5ae81b100..6f42441ca7 100644 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml @@ -8,8 +8,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/package.json b/package.json index c1013c23c6..3abd24ca1a 100644 --- a/package.json +++ b/package.json @@ -17,101 +17,102 @@ "build": "npm run build:lib && npm run build:web", "build:lib": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", - "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "tsc -b", - "lint": "tsx scripts/run-oxlint.ts .", - "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", - "duplication": "jscpd --config .jscpd.json packages scripts", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:snapshot": "vitest run --config vitest.snapshot.config.ts", - "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", - "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", - "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", - "test:web": "npm run build && npm run test:web:built", - "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts", - "test:web:built": "vitest run --config vitest.web.config.ts", - "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", - "check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary", - "check:ci:static": "tsx scripts/run-gates.ts ci-static", - "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", - "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", - "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", + "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", + "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", + "check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary", + "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", + "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", - "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", - "knip": "knip --treat-config-hints-as-errors", - "publint": "tsx scripts/publint-all.ts", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", + "clean": "tsx scripts/clean.ts", + "constraints": "tsx scripts/check-workspace-constraints.ts", + "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", + "demo:code-mode": "node scripts/demo-code-mode.mjs", + "demo:cordis": "node scripts/demo-cordis.mjs", + "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", + "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", + "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web", + "dev:web": "tsx scripts/dev-web.ts --poll", + "doc-sync": "tsx scripts/run-gates.ts doc-sync", "doc-typecheck": "tsx scripts/doc-typecheck.ts", - "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", - "verify-md-links": "tsx scripts/verify-md-links.ts", - "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", - "verify-package-paths": "tsx scripts/verify-package-paths.ts", - "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", - "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", - "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", - "verify-mermaid": "tsx scripts/verify-mermaid.ts", + "docs:build": "pnpm --filter @deepseek-ai/website run build", + "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build", + "docs:dev": "pnpm --filter @deepseek-ai/website run dev", + "docs:preview": "pnpm --filter @deepseek-ai/website run preview", + "dsh": "node --import tsx/esm apps/cli/src/bin.ts", + "duplication": "jscpd --config .jscpd.json packages scripts", + "gen-config-catalog": "tsx scripts/gen-config-catalog.ts", + "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", + "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", + "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts", + "gen-module-graph": "tsx scripts/gen-module-graph.ts", + "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", + "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", + "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", + "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", + "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "knip": "knip --treat-config-hints-as-errors", + "lint": "tsx scripts/run-oxlint.ts .", + "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", + "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", + "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", + "postinstall": "node scripts/install-lefthook.mjs", + "publint": "tsx scripts/publint-all.ts", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:gui": "vitest run packages/client packages/host", + "test:snapshot": "vitest run --config vitest.snapshot.config.ts", + "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", + "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", + "test:web": "npm run build && npm run test:web:built", + "test:web:built": "vitest run --config vitest.web.config.ts", + "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts", + "typecheck": "tsc -b", "verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts", "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts", "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", - "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", - "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", - "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", - "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", - "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", - "docs:dev": "pnpm --filter @deepseek-ai/website run dev", - "docs:build": "pnpm --filter @deepseek-ai/website run build", - "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa", - "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build", - "website:dev": "pnpm run docs:dev", - "website:build": "pnpm run docs:build", - "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", - "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", - "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", - "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", - "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", + "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", - "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", - "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", - "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", - "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check", - "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", - "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", - "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", - "gen-config-catalog": "tsx scripts/gen-config-catalog.ts", "verify-config-catalog": "tsx scripts/gen-config-catalog.ts --check", - "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts", + "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", + "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check", + "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", + "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", + "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check", - "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", - "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", - "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", - "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", - "gen-module-graph": "tsx scripts/gen-module-graph.ts", - "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", - "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", + "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", + "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", + "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", - "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", - "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node scripts/demo-cordis.mjs", - "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web", - "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", - "dev:web": "tsx scripts/dev-web.ts --poll", - "postinstall": "node scripts/install-lefthook.mjs" + "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", + "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", + "verify-package-paths": "tsx scripts/verify-package-paths.ts", + "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", + "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", + "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", + "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", + "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", + "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", + "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", + "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", + "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", + "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", + "website:build": "pnpm run docs:build", + "website:dev": "pnpm run docs:dev" }, "devDependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 644904676a..132db124b2 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -41,6 +42,7 @@ "devDependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index bc1214d11b..6d5db0776f 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -1,12 +1,29 @@ /** - * File-backed credentials provider layering the live process environment over - * a `$DSH_HOME/.credentials.yaml` document. The environment is authoritative - * and read-only (a launch-time override must win, and must be visibly - * read-only rather than silently shadow writes); the file is the - * provider-managed writable source: every write re-reads the document under a - * cross-process writer lock before patching only its own key — comments and - * the formatting of every untouched entry survive — external edits - * hot-publish through the seam, and each reload replaces the snapshot + * File-backed credentials provider over `$DSH_HOME/.credentials.yaml`, layered + * against the environment by how much each layer is trusted: + * + * ```text + * inherited process environment (read-only, wins) + * > $DSH_HOME/.credentials.yaml (provider-managed, writable) + * > $DSH_HOME/.env (read-only fallback) + * ``` + * + * The inherited environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI + * secret, or a container `-e` is this run's explicit intent; it cannot be + * edited from inside, so it must be *visibly* read-only rather than silently + * shadow writes. Everything below it loses to the managed store, so a key the + * web page or TUI writes takes effect immediately even when an older key sits + * in the user's `.env`. + * + * The invoking directory's `.env` supplies no credential at all. A project + * directory can be written by the model, and a substituted key would send + * every request — prompts included — through an account someone else reads; + * that decision belongs to the launching shell, not to a discovered file. + * + * The file is the provider-managed writable source: every write re-reads the + * document under a cross-process writer lock before patching only its own key + * — comments and the formatting of every untouched entry survive — external + * edits hot-publish through the seam, and each reload replaces the snapshot * wholesale so a deleted entry never lingers in memory. * * The document holds nothing but credentials, which is why it is a strict @@ -25,8 +42,10 @@ import { dirname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' +import type { EnvironmentEntry } from '@deepseek-ai/dsh-environment' /** Basename of the credentials document inside the harness home. */ export const CREDENTIALS_FILENAME = '.credentials.yaml' @@ -169,6 +188,18 @@ export class CredentialsLocal extends Credentials { this.spec = resolveSpec(config) } + /** The inherited-environment value for a reference, or `undefined` when empty or unset. */ + private inherited(ref: CredentialRef): string | undefined { + const entry = environmentOf(this.ctx).getFrom(ref, ['process']) + return entry !== undefined && entry.value.length > 0 ? entry.value : undefined + } + + /** The user `.env` fallback for a reference — below the managed store, never above it. */ + private userEnvFallback(ref: CredentialRef): EnvironmentEntry | undefined { + const entry = environmentOf(this.ctx).getFrom(ref, ['user-env']) + return entry !== undefined && entry.value.length > 0 ? entry : undefined + } + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { yield async () => { // Drain: refuse new operations, then settle the queued ones so disposal @@ -214,20 +245,27 @@ export class CredentialsLocal extends Credentials { } override resolve(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) + const inherited = this.inherited(ref) + if (inherited !== undefined) return Promise.resolve({ value: inherited, source: 'env' }) const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ value: stored, source: 'file' }) + const fallback = this.userEnvFallback(ref) + if (fallback !== undefined) return Promise.resolve({ value: fallback.value, source: 'user-env' }) return Promise.resolve(undefined) } override describe(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { + // Only the inherited environment is unwritable: it is the one layer this + // process cannot edit. A user `.env` value is writable in the sense that + // matters — storing a key replaces it as the effective one. + if (this.inherited(ref) !== undefined) { return Promise.resolve({ configured: true, source: 'env', writable: false }) } const stored = this.values.get(ref) if (stored !== undefined) return Promise.resolve({ configured: true, source: 'file', writable: true }) + if (this.userEnvFallback(ref) !== undefined) { + return Promise.resolve({ configured: true, source: 'user-env', writable: true }) + } return Promise.resolve({ configured: false, writable: true }) } @@ -303,13 +341,16 @@ export class CredentialsLocal extends Credentials { }) } - /** Reject a write the live environment would shadow into apparent no-effect. */ + /** + * Reject a write the inherited environment would shadow into apparent + * no-effect. Only that layer can shadow a write: everything else this + * provider resolves ranks below the document being written. + */ private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { + if (this.inherited(ref) !== undefined) { throw new Error( - `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` - + ' shadowed; unset it in the launching environment (or in a loaded .env) instead', + `credentials-local: "${ref}" is supplied read-only by the launching environment, so ${verb} would be` + + ' shadowed; unset it in the shell you start dsh from instead', ) } } diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index d5ffddc54d..abc3521111 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-environment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal, resolveSpec } from '../src/index.ts' @@ -100,6 +101,74 @@ describe('layering and reads', () => { }) }) +describe('layer ladder', () => { + // inherited process env > .credentials.yaml > $DSH_HOME/.env, and the + // invoking directory's .env supplies no credential at all. + async function bootLayered( + path: string, + layers: Parameters[0], + ): Promise { + const ctx = new Context() + ctx.provide(DSH_ENVIRONMENT_KEY, createEnvironmentSnapshot(layers)) + const fiber = ctx.plugin(CredentialsLocal, { path, watch: false }) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx + } + + it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') + const ctx = await bootLayered(path, [ + { source: 'process', values: {} }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' }) + // The old dead end is gone: a key sitting in the user's .env no longer + // makes the stored one unwritable. + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined() + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' }) + }) + + it('serves the user .env only when nothing is stored', async () => { + const dir = await tempDir() + const ctx = await bootLayered(join(dir, '.credentials.yaml'), [ + { source: 'process', values: {} }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' }) + // Writable: storing a key replaces it as the effective one. + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true }) + }) + + it('ignores the invoking directory .env entirely', async () => { + const dir = await tempDir() + const ctx = await bootLayered(join(dir, '.credentials.yaml'), [ + { source: 'process', values: {} }, + { source: 'project-env', path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } }, + ]) + // A project directory can be written by the model, and a substituted key + // would route every request through an account someone else reads. + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('lets only the inherited environment shadow the store, read-only', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + await writeFile(path, 'DSH_CRED_TEST: stored\n') + const ctx = await bootLayered(path, [ + { source: 'process', values: { DSH_CRED_TEST: 'from-shell' } }, + { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } }, + ]) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/) + }) +}) + describe('document validation', () => { // Every rejection below is a boot failure rather than a skipped entry: this // document holds nothing but credentials, so an ignored key would read as diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json index 3acfbdeffe..75e6b0aeb0 100644 --- a/packages/credentials/credentials-local/tsconfig.json +++ b/packages/credentials/credentials-local/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../util/atomic-write" }, + { + "path": "../../util/environment" + }, { "path": "../../util/paths" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 2c39e2d920..f9a114d908 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -28,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -40,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3ecc0bec77..effa080409 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -16,6 +16,7 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { environmentOf, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { @@ -62,7 +63,7 @@ export interface Config { apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string - /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ + /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' @@ -103,6 +104,9 @@ export const Config: z = z.object({ /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' +/** Environment variable naming this provider's endpoint, honored only from trusted layers. */ +const BASE_URL_ENV = 'DEEPSEEK_BASE_URL' + /** * One resolution's complete request facts. Connection and credential facts * are one value on purpose: a snapshot the resolver rejects keeps the whole @@ -142,9 +146,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * every default and bound is re-judged here — for the composition entry at * load (fail loud) and for each settings snapshot at its first use. * @param config - raw plugin config or resolved settings snapshot. + * @param environment - this run's environment layers, or `undefined` outside + * the product CLI. Only the launching shell and the user's own `.env` may + * supply an endpoint: a base URL decides where the resolved API key is sent, + * so a file inside the workspace must not be able to redirect it. * @returns validated connection facts plus the credential reference. */ -export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { +export function resolveAdapterOptions(config: Config, environment?: EnvironmentSnapshot): ResolvedDeepSeekOptions { if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { @@ -169,7 +177,9 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { return { ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), - baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, + baseURL: config.baseURL + ?? environment?.getFrom(BASE_URL_ENV, ['process', 'user-env'])?.value + ?? PUBLIC_BASE_URL, defaults: { thinking: config.thinking, reasoningEffort: config.reasoningEffort, @@ -190,7 +200,7 @@ export function apply(ctx: Context, config: Config): void { const raw = current() if (raw === lastRaw && lastGood !== undefined) return lastGood try { - const next = resolveAdapterOptions(raw) + const next = resolveAdapterOptions(raw, environmentOf(ctx)) lastRaw = raw lastGood = next return next @@ -217,10 +227,12 @@ export function apply(ctx: Context, config: Config): void { const hit = await credentials.resolve(ref) if (hit !== undefined) return hit.value } else { - // Without the seam, keep the historical ambient fallback so a plain - // cordis.yml composition works from the environment alone. - const ambient = process.env[ref] - if (ambient !== undefined && ambient.length > 0) return ambient + // Without the seam there is no managed store to rank against, so the + // launching environment is the whole credential plane — but only that + // layer: a key from a discovered project file would route this request + // through an account the launch never chose. + const inherited = environmentOf(ctx).getFrom(ref, ['process']) + if (inherited !== undefined && inherited.value.length > 0) return inherited.value } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..c9db376c8a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, errorChain, @@ -12,7 +13,7 @@ import LlmService, { createUserMessage, import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, PUBLIC_BASE_URL, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -906,6 +907,25 @@ describe('plugin registration and config', () => { expect(server.requests).toHaveLength(1) }) + + it('takes DEEPSEEK_BASE_URL from the launching shell or the user .env, never from the project', () => { + const trusted = createEnvironmentSnapshot([ + { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, + ]) + expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') + // A base URL decides where the resolved API key is sent, so a file inside + // a model-writable workspace must not be able to redirect it. + const project = createEnvironmentSnapshot([ + { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://attacker.example' } }, + ]) + expect(resolveAdapterOptions({}, project).baseURL).toBe(PUBLIC_BASE_URL) + // An explicitly configured endpoint outranks every environment layer, so a + // stale shell value cannot rewrite a deployment's own gateway. + const shell = createEnvironmentSnapshot([ + { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, + ]) + expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') + }) it('defaults to the public base URL without config or env', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'k') vi.stubEnv('DEEPSEEK_BASE_URL', undefined) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index ee8a81e73b..0b524a257b 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../util/environment" + }, { "path": "../../settings/settings" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 43b97a14f0..5e86ac5b40 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -28,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -40,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 91cb32a181..862aa2afca 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -29,6 +29,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import { LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' @@ -99,9 +100,9 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') const hit = credentials !== undefined ? (await credentials.resolve(ref))?.value - // Without the seam, read exactly the named variable so a plain - // cordis.yml composition works from the environment alone. - : process.env[ref] + // Without the seam the launching environment is the whole credential + // plane — but only that layer, never a discovered project file. + : environmentOf(ctx).getFrom(ref, ['process'])?.value if (hit !== undefined && hit.length > 0) return hit throw new LlmError( `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index ee8a81e73b..dd364e493a 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 18a42a27a1..fc4f173263 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -27,12 +27,14 @@ ], "license": "BSD-3-Clause", "dependencies": { + "dotenv": "^17.2.0", "js-yaml": "^4.2.0" }, "peerDependencies": { "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -48,6 +50,7 @@ "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 78dff3eca8..0f3cbd6687 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -9,11 +9,13 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { basename, dirname, resolve } from 'node:path' +import { parse as parseDotenv } from 'dotenv' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { createEnvironmentSnapshot, isBootstrapOnly, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -66,12 +68,57 @@ export function loadEnv( } /** - * Load the dsh product CLI's user environment: the invoking directory's `.env` + * Parse one directory's `.env` without applying it, rejecting any bootstrap + * variable it declares. A discovered file must not decide how this process + * launches, where its code and model-visible instructions come from, or how it + * reaches the network, so a violation fails the launch BEFORE anything is + * materialized — reporting it afterwards would leave the process already + * running under the value it refused. + * @param binName - the diagnostic prefix on the thrown error. + * @param dir - the directory whose `.env` to read. + * @param warn - sink for the one-line unreadable-file diagnostic. + * @returns the parsed entries, or `undefined` when the file is absent or unreadable. + * @throws when the file declares a name {@link isBootstrapOnly} rejects. + */ +function readEnvLayer( + binName: string, dir: string, warn: (line: string) => void, +): { path: string; values: Record } | undefined { + const path = resolve(dir, '.env') + let content: string + try { + content = readFileSync(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + warn(`${binName}: failed to load .env: ${String(error)}\n`) + } + // ENOENT (no .env) is fine — rely on the ambient environment. + return undefined + } + const values = parseDotenv(content) + for (const name of Object.keys(values)) { + if (!isBootstrapOnly(name)) continue + throw new Error( + `${binName}: ${path} sets "${name}", which only the launching environment may set` + + ' (it decides how this process starts, where its code and instructions load from, or how it' + + ` reaches the network); export ${name} instead of putting it in a .env file`, + ) + } + return { path, values } +} + +/** + * Load the dsh product CLI's user environment and return it as a snapshot that + * remembers which layer supplied each value: the invoking directory's `.env` * over the Harness home's `.env`, both under the inherited process - * environment. `process.loadEnvFile` never replaces a name that is already - * set, so loading the project file first and the user file second is what - * makes the layering `user < project < inherited`; the app-boot tests pin all - * three layers because that ordering is the whole contract. + * environment. + * + * Each layer is parsed and checked before anything is applied, then applied in + * the order that makes the layering `user < project < inherited` — + * `process.loadEnvFile` never replaces a name already set. Values do reach + * `process.env`, because a user's own `--config` tree and third-party + * libraries read it; the returned snapshot is the authority for everything the + * harness itself resolves, since `process.env` alone cannot say whether a + * value came from the launching shell or from a file inside the workspace. * * The Harness home is resolved from the inherited environment *before* either * file loads, so a project `.env` can never redirect which user document is @@ -82,17 +129,28 @@ export function loadEnv( * These are ordinary environment values with ordinary environment reach. A * secret the Harness should own and isolate belongs in the credentials * document, which is never materialized here. - * @param binName - the diagnostic prefix on the warn lines. + * @param binName - the diagnostic prefix on the diagnostics. * @param cwd - the invoking directory whose `.env` is the project layer. * @param warn - sink for the one-line misconfiguration diagnostics. + * @returns this run's frozen environment snapshot. + * @throws when either file declares a bootstrap-only variable. */ export function loadLayeredEnv( binName: string, cwd: string = process.cwd(), warn: (line: string) => void = line => void process.stderr.write(line), -): void { +): EnvironmentSnapshot { const home = resolveDshHome() - loadEnv(binName, cwd, warn) - loadEnv(binName, home, warn) + const inherited = { ...process.env } as Record + // Parse both layers first: a rejection must not leave one file applied. + const project = readEnvLayer(binName, cwd, warn) + const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) + if (project !== undefined) process.loadEnvFile(project.path) + if (user !== undefined) process.loadEnvFile(user.path) + return createEnvironmentSnapshot([ + { source: 'process', values: inherited }, + ...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }], + ...user === undefined ? [] : [{ source: 'user-env' as const, path: user.path, values: user.values }], + ]) } /** diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index ece98a9716..fba1ad1993 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -87,7 +87,7 @@ describe('loadEnv', () => { }) describe('loadLayeredEnv', () => { - const NAMES = ['DSH_APP_BOOT_LAYERED_SHARED', 'DSH_APP_BOOT_LAYERED_USER', 'DSH_APP_BOOT_LAYERED_PROJECT'] as const + const NAMES = ['APP_BOOT_LAYERED_SHARED', 'APP_BOOT_LAYERED_USER', 'APP_BOOT_LAYERED_PROJECT'] as const function clear(): void { for (const name of NAMES) Reflect.deleteProperty(process.env, name) @@ -99,18 +99,18 @@ describe('loadLayeredEnv', () => { writeFileSync(join(home, '.env'), [ `${NAMES[0]}=user`, `${NAMES[1]}=user-only`, - 'DSH_APP_BOOT_LAYERED_INHERITED=user-loses', + 'APP_BOOT_LAYERED_INHERITED=user-loses', '', ].join('\n')) writeFileSync(join(project, '.env'), [ `${NAMES[0]}=project`, `${NAMES[2]}=project-only`, - 'DSH_APP_BOOT_LAYERED_INHERITED=project-loses', + 'APP_BOOT_LAYERED_INHERITED=project-loses', '', ].join('\n')) clear() vi.stubEnv('DSH_HOME', home) - vi.stubEnv('DSH_APP_BOOT_LAYERED_INHERITED', 'inherited') + vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') const warn = vi.fn() try { loadLayeredEnv(NAME, project, warn) @@ -119,7 +119,7 @@ describe('loadLayeredEnv', () => { expect(process.env[NAMES[0]]).toBe('project') expect(process.env[NAMES[1]]).toBe('user-only') expect(process.env[NAMES[2]]).toBe('project-only') - expect(process.env['DSH_APP_BOOT_LAYERED_INHERITED']).toBe('inherited') + expect(process.env['APP_BOOT_LAYERED_INHERITED']).toBe('inherited') expect(warn).not.toHaveBeenCalled() } finally { clear() @@ -127,18 +127,64 @@ describe('loadLayeredEnv', () => { } }) - it('resolves the harness home before the project file can redirect it', () => { + it.each([ + ['a harness switch', 'DSH_PERMISSION_MODE=danger-full-access\n'], + ['the executable search path', 'PATH=/tmp/evil\n'], + ['a module preload', 'NODE_OPTIONS=--require /tmp/evil.js\n'], + ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'], + ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'], + ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'], + ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), `${NAMES[1]}=applied-anyway\n${content}`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/) + // Rejected BEFORE materialization: reporting the violation after the + // file was applied would leave the process running under what it refused. + expect(process.env[NAMES[1]]).toBeUndefined() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reports each layer with its absolute path', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(home, '.env'), `${NAMES[1]}=u\n`) + writeFileSync(join(project, '.env'), `${NAMES[2]}=p\n`) + clear() + vi.stubEnv('DSH_HOME', home) + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + { source: 'user-env', path: join(home, '.env') }, + ]) + expect(snapshot.get(NAMES[1])).toEqual({ value: 'u', source: 'user-env', path: join(home, '.env') }) + // getFrom is a refusal, not a demotion: an omitted layer is invisible. + expect(snapshot.getFrom(NAMES[2], ['process', 'user-env'])).toBeUndefined() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('resolves the harness home from the inherited environment, never from a file', () => { const home = tmp() - const decoy = tmp() const project = tmp() writeFileSync(join(home, '.env'), `${NAMES[1]}=real-home\n`) - writeFileSync(join(decoy, '.env'), `${NAMES[1]}=decoy-home\n`) - writeFileSync(join(project, '.env'), `DSH_HOME=${decoy}\n`) + writeFileSync(join(project, '.env'), `${NAMES[2]}=set-by-project\n`) clear() vi.stubEnv('DSH_HOME', home) try { loadLayeredEnv(NAME, project, vi.fn()) expect(process.env[NAMES[1]]).toBe('real-home') + expect(process.env[NAMES[2]]).toBe('set-by-project') } finally { clear() vi.unstubAllEnvs() diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index beb61317dc..18ddbedad3 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/environment" + }, { "path": "../../util/paths" } diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml new file mode 100644 index 0000000000..9d251d1940 --- /dev/null +++ b/packages/util/environment/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/util/environment/README.md +README.md: f642aa715c87878b2eaab9f034fb18163a6fbd2e +README.zh.md: a095730dbc8c2a4e7dc8dc57dc2930685c8fb453 diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md new file mode 100644 index 0000000000..f642aa715c --- /dev/null +++ b/packages/util/environment/README.md @@ -0,0 +1,42 @@ +# dsh-environment + +English | [中文](README.zh.md) + +This run's environment as one immutable snapshot that remembers **which layer supplied each value**. Consumers resolve user-facing values against it instead of `process.env`, because the layers are not equally trusted and a flattened view cannot tell them apart. + +| Layer | Source id | What it is | +|---|---|---| +| Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent | +| `/.env` | `project-env` | Whatever the project directory happens to contain; a model working in that workspace can write it | +| `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults | + +Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves. + +## Resolving + +`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the layers the caller trusts. + +**Omitting a layer is a refusal, not a demotion.** A base URL decides where a resolved API key is sent, so the LLM adapters ask for `['process', 'user-env']`: no future reordering can let a project file redirect a credential, because that layer is never consulted at all. + +```ts +import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' + +declare const ctx: Context +const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value +``` + +`environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with. + +## Bootstrap variables + +`isBootstrapOnly(name)` names the variables only the inherited environment may set. The launcher rejects a `.env` that declares one, before applying anything. + +A bootstrap variable decides **how a process launches** (`PATH`, `SHELL`, `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`), **where code or model-visible instructions load from** (the whole `DSH_*` namespace, `HOME`, `USERPROFILE`, `XDG_*`), or **how the network is reached and trusted** (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`). Matching is case-insensitive, so `https_proxy` is not a bypass. + +The whole `DSH_*` namespace is denied rather than an audited subset: the harness's own switches — the permission mode, the agents home, the bundled skill root — are exactly what a hostile project would want, and a switch added later must not become settable by forgetting to list it. + +## Known Limitations and Deferred Work + +- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables still reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. Bootstrap variables cannot come from a file at all, but a project `.env` can still set, say, `GIT_SSH_COMMAND` for the tools an agent runs. +- **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session. diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md new file mode 100644 index 0000000000..a095730dbc --- /dev/null +++ b/packages/util/environment/README.zh.md @@ -0,0 +1,42 @@ +# dsh-environment + +[English](README.md) | 中文 + +把本次运行的环境冻结为一份不可变快照,并记住**每个值来自哪一层**。消费方用它而不是 `process.env` 解析面向用户的值,因为各层的可信程度并不相同,而压平后的视图无法区分它们。 + +| 层 | 来源 id | 它是什么 | +|---|---|---| +| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 | +| `/.env` | `project-env` | 项目目录里恰好有的东西;在该工作区里工作的模型可以写它 | +| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 | + +这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。 + +## 解析 + +`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索调用方信任的层。 + +**省略某一层是拒绝,不是降级。** base URL 决定已解析的 API key 被发往何处,因此 LLM 适配器请求的是 `['process', 'user-env']`:后续任何重新排序都无法让项目文件重定向凭据,因为那一层根本不会被查询。 + +```ts +import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' + +declare const ctx: Context +const endpoint = environmentOf(ctx).getFrom('DEEPSEEK_BASE_URL', ['process', 'user-env'])?.value +``` + +当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。 + +## bootstrap 变量 + +`isBootstrapOnly(name)` 给出只有继承环境才能设置的变量。启动器一旦发现某个 `.env` 声明了其中之一,就会在应用任何内容之前拒绝启动。 + +bootstrap 变量决定**进程如何启动**(`PATH`、`SHELL`、`NODE_OPTIONS`、`NODE_PATH`、`LD_PRELOAD`、`LD_LIBRARY_PATH`、`DYLD_*`)、**代码或模型可见的指令从哪里加载**(整个 `DSH_*` 命名空间、`HOME`、`USERPROFILE`、`XDG_*`),或者**网络如何抵达与信任**(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`、`SSL_CERT_FILE`、`SSL_CERT_DIR`、`NODE_EXTRA_CA_CERTS`)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 + +整个 `DSH_*` 命名空间被拒绝,而不是只拒绝一份经过审查的子集:harness 自己的开关——权限模式、agents home、内置 skill(技能)根目录——恰恰是敌意项目最想要的,而后来新增的开关不能因为忘记登记就变得可设置。 + +## Known Limitations and Deferred Work + +- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此普通的项目变量仍会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。bootstrap 变量完全不能来自文件,但项目 `.env` 仍可以为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量。 +- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。 diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json new file mode 100644 index 0000000000..94a2a76ef6 --- /dev/null +++ b/packages/util/environment/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-environment", + "description": "Immutable launch-time environment snapshot with per-layer provenance for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts new file mode 100644 index 0000000000..100a0fe9f0 --- /dev/null +++ b/packages/util/environment/src/index.ts @@ -0,0 +1,178 @@ +/** + * The launch-time environment as one immutable snapshot that remembers which + * layer supplied each value. The harness resolves user-facing values against + * this rather than against `process.env`, because the layers differ in how + * much they are trusted: an inherited variable is this run's explicit intent, + * a file discovered under the invoking directory is whatever the project + * happens to contain, and a consumer that cannot tell them apart cannot make + * that distinction. + * + * Values still reach `process.env` as well — a user's own `--config` tree and + * third-party libraries read it — but that flattened view is not the + * authority for anything the harness itself resolves. + * @module @deepseek-ai/dsh-environment + */ + +import type { Context } from 'cordis' + +/** + * Which layer supplied a value, from most to least trusted: the environment + * this process inherited, the invoking directory's `.env`, the Harness home's + * `.env`. + */ +export type EnvironmentSource = 'process' | 'project-env' | 'user-env' + +/** Layer order, most trusted first — the default search order of {@link EnvironmentSnapshot.get}. */ +export const ENVIRONMENT_SOURCES: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env'] + +/** One resolved variable and the layer it came from. */ +export interface EnvironmentEntry { + /** The value as the layer supplied it; may be empty, which each owner judges for itself. */ + value: string + /** The layer that supplied it. */ + source: EnvironmentSource + /** Absolute path of the file that supplied it; absent for `process`. */ + path?: string +} + +/** One environment layer's identity, for diagnostics. */ +export interface EnvironmentLayer { + source: EnvironmentSource + /** Absolute path of the file behind this layer; absent for `process`. */ + path?: string +} + +/** + * The frozen environment of one launch. Construct through + * {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a + * later `chdir`, workspace switch, or resumed session observes the same + * values a consumer resolved at boot. + */ +export interface EnvironmentSnapshot { + /** + * Resolve one name across every layer, most trusted first. + * @param name - the variable name. + * @returns the winning entry, or `undefined` when no layer supplies it. + */ + get(name: string): EnvironmentEntry | undefined + /** + * Resolve one name across only the layers the caller trusts for this + * decision. Omitting a layer is a refusal, not a demotion: a routing field + * that must never come from a project directory omits `project-env` so no + * ordering change can let it back in. + * @param name - the variable name. + * @param sources - the layers to search, in the caller's own priority order. + * @returns the first matching entry, or `undefined`. + */ + getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined + /** The layers this snapshot was built from, most trusted first. */ + readonly layers: readonly EnvironmentLayer[] +} + +/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */ +export interface EnvironmentLayerInput { + source: EnvironmentSource + /** Absolute path of the file behind this layer; omit for `process`. */ + path?: string + values: Readonly> +} + +/** + * Build the snapshot from each layer's contents. + * @param layers - the layers in any order; the result searches them by {@link ENVIRONMENT_SOURCES}. + * @returns the immutable snapshot. + */ +export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot { + // Copied per layer so a later mutation of `process.env` — or of a caller's + // own object — cannot change what this snapshot reports. + const bySource = new Map }>() + for (const layer of layers) { + bySource.set(layer.source, { + ...layer.path === undefined ? {} : { path: layer.path }, + values: new Map(Object.entries(layer.values)), + }) + } + const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => { + for (const source of sources) { + const layer = bySource.get(source) + const value = layer?.values.get(name) + if (value === undefined) continue + return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } } + } + return undefined + } + return { + get: name => getFrom(name, ENVIRONMENT_SOURCES), + getFrom, + layers: ENVIRONMENT_SOURCES + .filter(source => bySource.has(source)) + .map((source): EnvironmentLayer => { + const path = bySource.get(source)?.path + return { source, ...path === undefined ? {} : { path } } + }), + } +} + +/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */ +export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment' + +/** + * The snapshot to resolve against, whatever booted this tree: the launcher's + * when the product CLI provided one, otherwise the inherited environment + * alone. + * + * The fallback does not weaken the layer rules — it applies the same rules to + * a host that has exactly one layer. An SDK embedder or a bare `cordis.yml` + * never discovered a project or user file, so everything it has really is the + * environment it was launched with, and `getFrom(..., ['process'])` is exactly + * right for it. + * @param ctx - the consuming plugin's context. + * @returns the snapshot to resolve user-facing values against. + */ +export function environmentOf(ctx: Context): EnvironmentSnapshot { + return ctx.get(DSH_ENVIRONMENT_KEY) + ?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record }]) +} + +declare module 'cordis' { + interface Context { + /** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */ + launcherEnvironment?: EnvironmentSnapshot + } +} + +/** Exact names no discovered file may set. */ +const BOOTSTRAP_NAMES = new Set([ + // Process launch and module resolution. + 'PATH', 'HOME', 'USERPROFILE', 'SHELL', + 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', + 'LD_PRELOAD', 'LD_LIBRARY_PATH', + // Network reach and trust. + 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', +]) + +/** Name prefixes no discovered file may set. */ +const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_'] + +/** + * Whether a variable may come only from the inherited process environment. + * + * A bootstrap variable decides how a process launches (`PATH`, `NODE_OPTIONS`, + * `LD_PRELOAD`), where code or model-visible instructions load from (`DSH_*` + * covers the Harness home, the agents home, and the bundled skill root), or + * how the network is reached and trusted (proxy and CA variables). A file the + * harness merely finds — including one a model can write inside the workspace + * — must never set them, so they are rejected at load rather than ranked + * below another layer. + * + * The whole `DSH_*` namespace is denied rather than an audited subset: the + * harness's own switches are exactly the ones a hostile project would want, + * and a new switch must not become settable by forgetting to list it. + * @param name - the variable name. + * @returns true when only the inherited environment may supply it. + */ +export function isBootstrapOnly(name: string): boolean { + const upper = name.toUpperCase() + return BOOTSTRAP_NAMES.has(upper) || BOOTSTRAP_PREFIXES.some(prefix => upper.startsWith(prefix)) +} diff --git a/packages/util/environment/src/invariant.ts b/packages/util/environment/src/invariant.ts new file mode 100644 index 0000000000..96e53828ae --- /dev/null +++ b/packages/util/environment/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-environment`. + * @module @deepseek-ai/dsh-environment/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-environment' + +/** Cordis companion plugin name. */ +export const name = 'environment-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no + * event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts new file mode 100644 index 0000000000..27c7b16e55 --- /dev/null +++ b/packages/util/environment/tests/environment.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { + createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, ENVIRONMENT_SOURCES, environmentOf, isBootstrapOnly, +} from '../src/index.ts' + +const layered = createEnvironmentSnapshot([ + { source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } }, + { source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } }, + { source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } }, +]) + +describe('createEnvironmentSnapshot', () => { + it('resolves across every layer, most trusted first, and reports the winning source', () => { + expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' }) + expect(layered.get('ONLY_PROJECT')).toEqual({ value: 'j', source: 'project-env', path: '/work/.env' }) + expect(layered.get('ONLY_USER')).toEqual({ value: 'u', source: 'user-env', path: '/home/.dsh/.env' }) + expect(layered.get('ABSENT')).toBeUndefined() + }) + + it('treats an omitted layer as invisible, not merely lower', () => { + // The point of getFrom: a routing field that must never come from a + // project directory cannot be reached by reordering, only by listing it. + expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined() + expect(layered.getFrom('SHARED', ['user-env', 'process'])).toEqual({ + value: 'from-user', source: 'user-env', path: '/home/.dsh/.env', + }) + expect(layered.getFrom('SHARED', [])).toBeUndefined() + }) + + it('lists its layers in trust order with their paths', () => { + expect(layered.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: '/work/.env' }, + { source: 'user-env', path: '/home/.dsh/.env' }, + ]) + expect(createEnvironmentSnapshot([{ source: 'process', values: {} }]).layers).toEqual([{ source: 'process' }]) + }) + + it('copies each layer, so a later mutation of the source object cannot change it', () => { + const values: Record = { KEY: 'first' } + const snapshot = createEnvironmentSnapshot([{ source: 'process', values }]) + values.KEY = 'second' + values.LATE = 'added' + expect(snapshot.get('KEY')).toEqual({ value: 'first', source: 'process' }) + expect(snapshot.get('LATE')).toBeUndefined() + }) + + it('keeps an empty value as a present value, for its owner to judge', () => { + const snapshot = createEnvironmentSnapshot([{ source: 'process', values: { EMPTY: '' } }]) + expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' }) + }) + + it('orders lookups by ENVIRONMENT_SOURCES regardless of construction order', () => { + const reversed = createEnvironmentSnapshot([ + { source: 'user-env', path: '/u', values: { K: 'u' } }, + { source: 'process', values: { K: 'p' } }, + ]) + expect(ENVIRONMENT_SOURCES).toEqual(['process', 'project-env', 'user-env']) + expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' }) + }) +}) + +describe('environmentOf', () => { + it('returns the launcher snapshot when the product CLI provided one', () => { + const ctx = new Context() + ctx.provide(DSH_ENVIRONMENT_KEY, layered) + expect(environmentOf(ctx)).toBe(layered) + }) + + it('falls back to the inherited environment as the only layer', () => { + vi.stubEnv('DSH_ENV_SPEC_FALLBACK', 'ambient') + try { + const snapshot = environmentOf(new Context()) + expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' }) + // A host that discovered no files has exactly one layer, so the trusted + // lookups every consumer makes still find what it was launched with. + expect(snapshot.getFrom('DSH_ENV_SPEC_FALLBACK', ['process', 'user-env'])?.value).toBe('ambient') + expect(snapshot.layers).toEqual([{ source: 'process' }]) + } finally { + vi.unstubAllEnvs() + } + }) +}) + +describe('isBootstrapOnly', () => { + it.each([ + 'PATH', 'HOME', 'USERPROFILE', 'SHELL', + 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', + 'LD_PRELOAD', 'LD_LIBRARY_PATH', + 'SSL_CERT_FILE', 'SSL_CERT_DIR', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + ])('rejects %s, which decides how the process starts or reaches the network', (name) => { + expect(isBootstrapOnly(name)).toBe(true) + }) + + it.each([ + ['DSH_HOME', 'the harness home'], + ['DSH_PERMISSION_MODE', 'the permission mode'], + ['DSH_AGENTS_HOME', 'a model-visible instruction root'], + ['DSH_ANYTHING_ADDED_LATER', 'a switch that does not exist yet'], + ['XDG_CONFIG_HOME', 'a state root'], + ['DYLD_INSERT_LIBRARIES', 'a library preload'], + ])('rejects the whole namespace: %s (%s)', (name) => { + expect(isBootstrapOnly(name)).toBe(true) + }) + + it('matches case-insensitively, so a lowercase proxy name is not a bypass', () => { + expect(isBootstrapOnly('https_proxy')).toBe(true) + expect(isBootstrapOnly('dsh_permission_mode')).toBe(true) + }) + + it('allows ordinary variables, including provider credentials and endpoints', () => { + for (const name of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'EXA_API_KEY', 'MY_PROJECT_FLAG', 'PATHS']) { + expect(isBootstrapOnly(name)).toBe(false) + } + }) +}) diff --git a/packages/util/environment/tsconfig.json b/packages/util/environment/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/environment/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index e1dbf720f6..b286c5d421 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 8569f0e944..3a7e1f65a9 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { environmentOf } from '@deepseek-ai/dsh-environment' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' import { @@ -80,8 +81,10 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey: async () => { const credentials = ctx.get('credentials') if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value - const ambient = process.env[apiKeyEnv] - return ambient !== undefined && ambient.length > 0 ? ambient : undefined + // Without the seam the launching environment is the whole credential + // plane — but only that layer, never a discovered project file. + const inherited = environmentOf(ctx).getFrom(apiKeyEnv, ['process']) + return inherited !== undefined && inherited.value.length > 0 ? inherited.value : undefined }, apiKeyEnv, baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index 76c411d089..b3d8e2ade6 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 7d6b802d2e..b9c2fba351 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index 67b2eed574..87a8e6572e 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -9,6 +9,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' import { @@ -58,7 +59,10 @@ export const Config: z = z.object({ /** Register the Exa search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new ExaSearchProvider({ - apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '', + // Only the launching shell and the user's own `.env` may name this key: + // a project directory can be written by the model, and a substituted key + // would route every request through an account someone else reads. + apiKey: config.apiKey ?? environmentOf(ctx).getFrom('EXA_API_KEY', ['process', 'user-env'])?.value ?? '', baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index e9610ea5c9..770ee55a04 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 9aa7080431..5f64df89ee 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index d673f575c8..b2b5804a92 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -8,6 +8,7 @@ */ import type { Context } from 'cordis' +import { environmentOf } from '@deepseek-ai/dsh-environment' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' @@ -52,7 +53,10 @@ export const Config: z = z.object({ /** Register the Perplexity search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new PerplexitySearchProvider({ - apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '', + // Only the launching shell and the user's own `.env` may name this key: + // a project directory can be written by the model, and a substituted key + // would route every request through an account someone else reads. + apiKey: config.apiKey ?? environmentOf(ctx).getFrom('PERPLEXITY_API_KEY', ['process', 'user-env'])?.value ?? '', baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, model: config.model ?? PERPLEXITY_DEFAULT_MODEL, maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index e9610ea5c9..770ee55a04 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/environment" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a74fc2677f..ac412c983a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../packages/credentials/credentials-local + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web @@ -2638,6 +2641,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3609,6 +3615,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3637,6 +3646,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5673,6 +5685,9 @@ importers: packages/ui/app-boot: dependencies: + dotenv: + specifier: ^17.2.0 + version: 17.4.2 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -5689,6 +5704,9 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5988,6 +6006,15 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/util/environment: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/util/native-command: devDependencies: '@deepseek-ai/dsh-invariants': @@ -6129,6 +6156,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6148,6 +6178,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6164,6 +6197,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../util/environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6420,6 +6456,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -9776,6 +9815,10 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -14828,6 +14871,8 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv@17.4.2: {} + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a4d555055d..bfbf63ba9a 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -24,6 +24,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f2173478f1..92c4d31d59 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -569,6 +569,7 @@ function docSyncLeafGates(options: { pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }), + pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }), pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }), pnpmScript('mermaid', 'verify-mermaid'), pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }), diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts new file mode 100644 index 0000000000..d346b19233 --- /dev/null +++ b/scripts/verify-config-source-ownership.ts @@ -0,0 +1,117 @@ +/** + * Gate: every user-facing value has one owner, and no shipped file smuggles a + * second one in. + * + * Two rules, both about the same failure — a value reaching the harness + * through a path nobody ranked: + * + * 1. Production package source does not read `process.env` directly. A + * credential belongs to `ctx.credentials`, a user-configurable value to the + * environment snapshot plus its owner's resolve step, and a real + * process-launch fact to the app bootstrap. Each remaining read is listed + * below with the reason it is one of those. + * 2. Shipped Cordis configuration does not inline a credential or an endpoint + * from the environment. Doing so re-creates the layer the snapshot exists + * to rank: `apiKey: !!js process.env.X` and `baseURL: !!js process.env.X` + * bypass both the credential seam and the endpoint ladder, and a project + * file could then decide where a key is sent. + * @module scripts/verify-config-source-ownership + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' + +const ROOT = resolve(import.meta.dirname, '..') + +/** + * Production package sources allowed to read `process.env`, each with the + * reason it is a process fact rather than a user-configurable value. Adding a + * row is a deliberate act: state which of the three owners it belongs to and + * why it cannot go there. + */ +const ENV_READ_ALLOWLIST: Readonly> = { + // The environment plane itself. + 'packages/util/environment/src/index.ts': 'defines the snapshot; the inherited environment is its input', + 'packages/ui/app-boot/src/index.ts': 'the app bootstrap that builds the snapshot and reads $DSH_SNAPSHOT', + 'packages/util/paths/src/index.ts': 'resolves $DSH_HOME before any snapshot exists', + + // Process-launch facts owned by the boundary that spawns or is spawned. + 'packages/subprocess/subprocess/src/index.ts': 'scrubs the parent environment for children', + 'packages/workflow/workflow-workerthread/src/host.ts': 'passes the parent environment to a worker thread', + 'packages/ui/tui/src/index.ts': 'reads $COLORTERM, a terminal capability of this process', + 'packages/lsp/lsp-local/src/index.ts': 'passes the parent environment to a language server it spawns', + 'packages/cordis/repository-plugin/src/index.ts': 'resolves an MCP manifest against the spawning environment', + + // Bootstrap-only DSH_* switches, which no discovered file may set. + 'packages/skill/skill-local/src/index.ts': 'reads $DSH_AGENTS_HOME and $DSH_BUNDLED_SKILL_DIR, both bootstrap-only', + 'packages/web/web/src/index.ts': 'reads $DSH_WEB_SEARCH_PROVIDER and $DSH_WEB_FETCH_PROVIDER, both bootstrap-only', + 'packages/host/directory-picker-auto/src/index.ts': 'reads launch facts (display, SSH) of this process', + 'packages/host/directory-picker-auto/src/resolve.ts': 'reads launch facts (display, SSH) of this process', + + // Telemetry identity and consent, resolved once per process at bootstrap. + 'packages/telemetry/session-telemetry-otel/src/user-id.ts': 'derives a machine identity from process facts', + 'packages/sdk/telemetry/src/consent-resolver.ts': 'reads the SDK bootstrap consent switch', + 'packages/sdk/telemetry/src/anonymous-id.ts': 'derives a machine identity from process facts', + + // SDK and example bins: their own app bootstrap, outside the product CLI. + 'packages/sdk/sdk-client/src/client.ts': 'SDK host bootstrap', + 'packages/sdk/helper/src/features/builtin/provider.ts': 'SDK scaffolding reads the developer environment', + 'packages/sdk/helper/src/features/builtin/app.ts': 'SDK scaffolding reads the developer environment', + 'packages/sdk/helper/src/package-managers/package-manager.ts': 'detects the invoking package manager', + 'packages/sdk/create-sdk/src/create-wizard.ts': 'SDK scaffolding reads the developer environment', + 'packages/examples/jsonrpc-demo/src/bin.ts': 'demo bin bootstrap', + 'packages/examples/acp-demo/src/bin.ts': 'demo bin bootstrap', + + // Test and replay infrastructure. + 'packages/support/loader-smoke/src/index.ts': 'test launcher composing a child environment', + 'packages/support/llm-replay/src/index.ts': 'replay fixture switch', + 'packages/support/acp-snapshot/src/launcher.ts': 'snapshot launcher composing a child environment', + + // Browser bundle: `process.env` is replaced at build time, never read at runtime. + 'packages/client/runtime/src/client/contract/store.ts': 'build-time constant folded by the bundler', +} + +/** Shipped Cordis configuration these rules apply to. */ +const SHIPPED_CONFIG_GLOBS = ['apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml'] + +/** Config keys that must never be inlined from the environment. */ +const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ + +const failures: string[] = [] + +for (const file of globSync('packages/*/*/src/**/*.ts', { cwd: ROOT })) { + const rel = file.split(sep).join('/') + if (!readFileSync(resolve(ROOT, rel), 'utf8').includes('process.env')) continue + if (rel in ENV_READ_ALLOWLIST) continue + failures.push( + `${rel}: reads process.env directly. A credential belongs to ctx.credentials, a user-configurable` + + ' value to environmentOf(ctx) plus its owner\'s resolve step, and a process-launch fact to the app' + + ' bootstrap. If it is genuinely one of those, add it to ENV_READ_ALLOWLIST with the reason.', + ) +} + +for (const glob of SHIPPED_CONFIG_GLOBS) { + for (const file of globSync(glob, { cwd: ROOT })) { + const rel = file.split(sep).join('/') + readFileSync(resolve(ROOT, rel), 'utf8').split('\n').forEach((line, index) => { + if (!INLINE_DENY.test(line)) return + failures.push( + `${rel}:${String(index + 1)}: inlines a credential or endpoint from the environment.` + + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + + ' environment snapshot; inlining here bypasses both ladders.', + ) + }) + } +} + +if (failures.length > 0) { + process.stderr.write('verify-config-source-ownership: configuration source ownership violated:\n') + for (const failure of failures) process.stderr.write(` ${failure}\n`) + process.exit(1) +} + +const allowed = Object.keys(ENV_READ_ALLOWLIST).length +process.stdout.write( + `verify-config-source-ownership: no unregistered process.env reads (${String(allowed)} allowlisted)` + + ' and no credential or endpoint inlined in shipped configuration.\n', +) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4104ff8fdc..641daefa01 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -33,6 +33,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', 'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', + 'packages/util/environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.', } /** diff --git a/tsconfig.host.json b/tsconfig.host.json index 82abd3cfc3..0e3aa509fa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -72,6 +72,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, From 8c2970e70ef7aa3bcf923648e5bb06447efd74b8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 17:16:11 +0800 Subject: [PATCH 05/88] 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 f22cacc63b7c503cdde6915ba6c21b98774e8cfe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:49:21 +0800 Subject: [PATCH 06/88] fix: advance resolving issue status from PRs --- ...-04-forward-only-pr-issue-status.i18n.yaml | 6 +++ ...2026-08-04-forward-only-pr-issue-status.md | 39 ++++++++++++++++ ...6-08-04-forward-only-pr-issue-status.zh.md | 39 ++++++++++++++++ .github/issue-management/policy.mjs | 32 ++++++++++---- .github/issue-management/policy.test.mjs | 44 +++++++++++++++++++ package.json | 1 + scripts/run-gates.ts | 3 ++ 7 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml new file mode 100644 index 0000000000..1b704da8f1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.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-04-forward-only-pr-issue-status.md +2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76 +2026-08-04-forward-only-pr-issue-status.zh.md: f19cceafbde074d298a7c7f27829c8ab919f00b6 diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md new file mode 100644 index 0000000000..dd567707bc --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md @@ -0,0 +1,39 @@ +# Agent Note: Forward-only PR-to-Issue status projection + +Status: implemented + +English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md) + +## Problem + +The Issue Project status represents the phase of the work, while an exact same-repository resolving keyword establishes the authoritative PR-to-Issue relationship. Restricting lifecycle advancement to Issues already in `Ready` leaves an Issue in `Inbox` or `Backlog` after implementation has demonstrably started. Requiring otherwise valid PR metadata before projecting the phase also conflates policy compliance with the work's observable state. + +## Decision + +PR and PR-review events project the current PR phase to every exact same-repository resolving Issue. A draft PR, or a non-draft PR without a review request or submitted review, targets `In progress`. A non-draft PR with either form of review activity targets `In review`. + +The active statuses have the order `Inbox`, `Backlog`, `Ready`, `In progress`, and `In review`. Projection writes only when the target is later in that order. It does not move an Issue backward, alter `Done` or `No action`, or add an Issue that has no Project status. The lifecycle path is independent of PR metadata validation; the separate required PR policy check continues to enforce labels, references, and priority consistency. + +This projection is intentionally one-way. It does not query from an Issue to related PRs, and it does not add a scheduled reconciler. PR events are the source of lifecycle advancement. The pure transition decision is exercised by the Issue-management test and that test runs in the `check-all`, `ci-primary`, and `ci-static` gates. + +## Verification + +`.github/issue-management/policy.test.mjs` covers advancement from every earlier active status, the draft and review distinctions, metadata-policy independence, and protection against backward or terminal transitions. `scripts/run-gates.ts` owns execution of that focused policy test in top-level local and CI gate modes. + +## Alternatives considered + +**Require `Ready` as the only source status.** This preserves a manual prerequisite but leaves stale `Inbox` and `Backlog` items even though the resolving PR proves implementation has begun. + +**Add bidirectional or scheduled reconciliation.** Looking up PRs from Issue events or sweeping the Project could repair more histories, but it adds another authority direction and recurring API work beyond the required PR-driven lifecycle. + +**Gate projection on complete PR metadata.** Labels, references, and priority still require enforcement, but a metadata defect does not make the implementation or review phase untrue. + +**Move statuses backward when a PR becomes a draft or loses reviewers.** That would make transient PR state overwrite a later observed work phase and complicate status ownership. Projection therefore remains monotonic. + +## Consequences + +- A PR event self-corrects a resolving Issue left in `Inbox`, `Backlog`, or `Ready`. +- An Issue created after the last relevant PR event waits for a later PR event or a manual status update because there is no reverse lookup or scheduled sweep. +- A draft PR remains `In progress` even if it has historical review activity; only a non-draft PR targets `In review`. +- Terminal statuses and later active statuses remain protected from regression. +- PR metadata failures remain visible through the required policy check without suppressing lifecycle projection. diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md new file mode 100644 index 0000000000..f19cceafbd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -0,0 +1,39 @@ +# Agent Note: PR 到 Issue 的状态仅向前投射 + +Status: implemented + +[English](2026-08-04-forward-only-pr-issue-status.md) | 中文 + +## 问题 + +Issue Project 状态表示工作所处阶段,同仓库内精确匹配的解决型关键字引用则建立权威的 PR(Pull Request)到 Issue 关系。若仅允许已处于 `Ready` 的 Issue 推进生命周期,即使实现已经明确开始,处于 `Inbox` 或 `Backlog` 的 Issue 仍会停留在原状态。只有 PR 元数据在其他方面均有效时才投射工作阶段,也会把政策合规性与可观察到的工作状态混为一谈。 + +## 决策 + +PR 事件和 PR 评审事件会把当前 PR 阶段投射到同仓库内被精确引用的每个解决型 Issue。草稿 PR,或既没有评审请求也没有已提交评审的非草稿 PR,目标状态为 `In progress`。具备上述任一类评审活动的非草稿 PR,目标状态为 `In review`。 + +活跃状态依次为 `Inbox`、`Backlog`、`Ready`、`In progress` 和 `In review`。只有目标状态在该顺序中位于当前状态之后时,投射才会写入。投射不会把 Issue 状态向后移动,不会改动 `Done` 或 `No action`,也不会把没有 Project 状态的 Issue 加入 Project。生命周期路径独立于 PR 元数据校验;另行执行的必需 PR 政策检查继续强制落实标签、引用和优先级一致性。 + +这项投射刻意保持单向。它不会从 Issue 反查关联 PR,也不会添加定时对账任务。PR 事件是推进生命周期的来源。Issue 管理测试会验证纯函数实现的状态转换决策,并且该测试会在 `check-all`、`ci-primary` 和 `ci-static` 门禁中运行。 + +## 验证 + +`.github/issue-management/policy.test.mjs` 覆盖从所有更早活跃状态推进、区分草稿与评审状态、独立于元数据政策,以及防止状态倒退或改动终态。`scripts/run-gates.ts` 负责在顶层本地门禁模式和 CI 门禁模式中执行这项专项政策测试。 + +## 考虑过的替代方案 + +**仅允许从 `Ready` 状态推进。** 这种方案保留了人工前置条件,但解决型 PR 已经证明实现开始后,仍会让处于 `Inbox` 和 `Backlog` 的条目保持陈旧状态。 + +**增加双向或定时对账。** 由 Issue 事件反查 PR,或定期扫描 Project,可以修复更多历史遗留状态;但这会新增一条反向的权威状态更新路径,并增加周期性 API 工作量,超出所需的 PR 驱动生命周期范围。 + +**以完整的 PR 元数据作为投射前提。** 标签、引用和优先级仍须强制落实,但元数据缺陷并不能否定工作实际处于实现或评审阶段。 + +**PR 转为草稿或失去评审人时将状态向后移动。** 这会让临时的 PR 状态覆盖已经观察到的更靠后工作阶段,也会使状态所有权更复杂。因此,投射保持单调。 + +## 后果 + +- PR 事件会自动纠正停留在 `Inbox`、`Backlog` 或 `Ready` 的解决型 Issue。 +- 若 Issue 创建于最后一个相关 PR 事件之后,则必须等待后续 PR 事件或人工更新状态,因为系统不会反向查找或定时扫描。 +- 即使存在历史评审活动,草稿 PR 仍保持 `In progress`;只有非草稿 PR 才会以 `In review` 为目标状态。 +- 终态以及顺序中更靠后的活跃状态不会倒退。 +- 必需的政策检查仍会暴露 PR 元数据错误,而不会因此阻止生命周期投射。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 4c9242bab5..73703bd199 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,6 +12,7 @@ const AUDIT_MARKER = '' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] +const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] /** * Return Markdown outside balanced details elements. @@ -129,6 +130,22 @@ export function requiresPullRequestPolicy({ return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) } +/** + * Derive a forward-only Issue status from the current PR phase. + * @param {string|null} currentStatus Current Project status. + * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase. + * @returns {string|null} Status to write, or null when no forward transition exists. + */ +export function nextResolvingIssueStatus(currentStatus, pull) { + const target = + !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0) + ? 'In review' + : 'In progress' + const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus) + const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target) + return currentIndex >= 0 && currentIndex < targetIndex ? target : null +} + function stripIgnoredMarkdown(body) { const lines = body.replace(//g, '').split(/\r?\n/) const kept = [] @@ -491,11 +508,13 @@ async function pullRequestSnapshot(number) { } } -async function moveResolvingIssues(pull, from, to) { +async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { const current = await issueSnapshot(number) - if (!current || current.status !== from) continue - await setStatus(number, to) + if (!current) continue + const target = nextResolvingIssueStatus(current.status, pull) + if (!target) continue + await setStatus(number, target) await auditIssue(number) } } @@ -530,12 +549,7 @@ async function runLifecycle(eventName, event) { if (eventName === 'pull_request' || eventName === 'pull_request_review') { const pull = await pullRequestSnapshot(event.pull_request.number) - const errors = validatePullRequest(pull) - if (errors.length > 0) return - await moveResolvingIssues(pull, 'Ready', 'In progress') - if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { - await moveResolvingIssues(pull, 'In progress', 'In review') - } + await advanceResolvingIssues(pull) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8e0c253796..86750127a7 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test' import { countVisibleUnits, + nextResolvingIssueStatus, parseReferences, retainIssueReferences, requiresPullRequestPolicy, @@ -191,6 +192,49 @@ test('requires policy only after a human PR enters review', () => { ) }) +test('advances resolving Issues to the live PR phase', () => { + const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 } + const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 } + const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + for (const status of ['Inbox', 'Backlog', 'Ready']) { + assert.equal(nextResolvingIssueStatus(status, draft), 'In progress') + assert.equal(nextResolvingIssueStatus(status, open), 'In progress') + assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review') + } + assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review') +}) + +test('never regresses or reopens a resolving Issue', () => { + const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + assert.equal(nextResolvingIssueStatus('In progress', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', review), null) + assert.equal(nextResolvingIssueStatus('Done', review), null) + assert.equal(nextResolvingIssueStatus('No action', review), null) + assert.equal(nextResolvingIssueStatus(null, review), null) +}) + +test('keeps lifecycle projection independent of PR metadata enforcement', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + + assert.ok(validatePullRequest(pull).length > 0) + assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review') +}) + test('exempts Draft, Bot, and App PRs', () => { const invalid = { isDraft: false, diff --git a/package.json b/package.json index fef0a1eb53..fd7f5447ff 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:issue-management": "node --test .github/issue-management/policy.test.mjs", "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", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..956617c024 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -224,6 +224,7 @@ export function gatesForMode(selected: Mode): Gate[] { pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }), pnpmScript('test', 'test'), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('duplication', 'duplication'), snapshotGate(), pnpmScript('build', 'build'), @@ -246,6 +247,7 @@ function ciPrimaryGates(): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -343,6 +345,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From 7aa0ae34b372bb5b91571830c416c661da1ae33f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:12:55 +0800 Subject: [PATCH 07/88] fix: harden issue status projection --- .github/issue-management/policy.mjs | 23 ++++++++++++++++------- scripts/run-gates.ts | 14 ++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 73703bd199..608291c4f8 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,7 +12,12 @@ const AUDIT_MARKER = '' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] -const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] +const TERMINAL_STATUSES = new Set(['Done', 'No action']) +const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) + +for (const status of ['In progress', 'In review']) { + if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`) +} /** * Return Markdown outside balanced details elements. @@ -418,8 +423,7 @@ async function ensureProjectItem(number) { } } -async function setStatus(number, status) { - const context = await ensureProjectItem(number) +async function updateStatus(context, status) { const option = context.statusField.options.find((candidate) => candidate.name === status) if (!option) throw new Error(`Status 不存在:${status}`) if (context.item.fieldValueByName?.name === status) return @@ -441,6 +445,10 @@ async function setStatus(number, status) { ) } +async function setStatus(number, status) { + await updateStatus(await ensureProjectItem(number), status) +} + async function upsertAudit(number, errors) { const comments = await api( `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, @@ -510,11 +518,12 @@ async function pullRequestSnapshot(number) { async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { - const current = await issueSnapshot(number) - if (!current) continue - const target = nextResolvingIssueStatus(current.status, pull) + const context = await projectContext(number) + const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) if (!target) continue - await setStatus(number, target) + // TODO: Replace this latest-state guard with per-Issue serialization or a + // conditional ProjectV2 update; GraphQL currently has no compare-and-swap. + await updateStatus(context, target) await auditIssue(number) } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 956617c024..7503c77072 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -241,13 +241,19 @@ export function gatesForMode(selected: Mode): Gate[] { } } -function ciPrimaryGates(): Gate[] { +function ciSharedStaticGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ] +} + +function ciPrimaryGates(): Gate[] { + return [ + ...ciSharedStaticGates(), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -341,11 +347,7 @@ function runningNodeMajor(): number { function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { return [ - pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), - pnpmScript('constraints', 'constraints'), - pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), - pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ...ciSharedStaticGates(), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From 590b76a7f018d61a13c89155904bb6e4fc4e8df1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 11:18:06 +0800 Subject: [PATCH 08/88] fix(config): close the review findings on configuration source ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two had real security consequences: The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile applied the file with Node's own. Two independently maintained dialects meant the check and the thing it guards could disagree: a name Node accepts but the checker misses would reach process.env unchecked, and BASH_ENV there runs a file of the project's choosing on every `bash -c` the bash tool issues. Parse once with node:util's parseEnv — the same engine loadEnvFile uses — and assign the entries already checked, which also drops the dotenv dependency. llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it registers a settings namespace, so the defect removed from llm-deepseek survived intact in its design twin. The field is gone from the profile schema, the resolution path, and the tests. The rest are consistency and documentation defects the review named: - verify-config-source-ownership did not scan the Python runtime's bundled cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and the line-anchored INLINE_DENY documents that it is a tripwire, not a parser. - The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated rule about what a variable does. - Snapshot lookups folded case on Windows, where environment names are case-insensitive and an exact-match Map could miss a higher-ranked layer. - The credentials note claimed a read-time permission check was "not taken" while this PR implemented it; the credentials-local README still described two layers, live process.env reads, dotenv-era limitations, and a renamed anchor; the llm-deepseek README still advertised the removed literal apiKey; and web.ts and base.cordis.yml kept personal-overlay wording. - The ownership note's literal-apiKey claim now names its scope: the web-search providers keep a literal field but register no settings namespace, so nothing can shadow a stored credential through them. --- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 2 +- ...08-04-configuration-source-ownership.zh.md | 2 +- ...-yaml-and-user-environment-layer.i18n.yaml | 4 +- ...entials-yaml-and-user-environment-layer.md | 2 +- ...ials-yaml-and-user-environment-layer.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 1 - apps/cli/config/base.cordis.yml | 5 ++- apps/cli/src/web.ts | 2 +- docs/config-catalog.md | 4 +- .../credentials.i18n.yaml | 4 +- docs/core-data-structures/credentials.md | 2 +- docs/core-data-structures/credentials.zh.md | 2 +- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 24 +++++++---- .../credentials-local/README.zh.md | 24 +++++++---- .../credentials-local/src/index.ts | 6 +-- packages/credentials/credentials/src/index.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 6 +-- .../llm-deepseek/tests/dynamic-config.spec.ts | 6 +-- packages/llm/llm-pi-ai/src/config.ts | 6 --- packages/llm/llm-pi-ai/src/index.ts | 1 - packages/llm/llm-pi-ai/tests/adapter.spec.ts | 40 ++++++++++++------- .../llm-pi-ai/tests/dynamic-config.spec.ts | 26 +++++++++--- .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 2 +- packages/ui/app-boot/package.json | 1 - packages/ui/app-boot/src/index.ts | 32 +++++++++++---- packages/util/environment/src/index.ts | 32 +++++++++++++-- pnpm-lock.yaml | 9 ----- .../runtime/cordis.yml | 9 ++--- scripts/verify-config-source-ownership.ts | 16 +++++++- 33 files changed, 180 insertions(+), 110 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 0bc04dc2bb..cbce8a65e8 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 101c0e6ba4954b3fbb418b775322a9fd92c46a8c -2026-08-04-configuration-source-ownership.zh.md: ad59f9a96e144dd5078898da57195a8bb6897451 +2026-08-04-configuration-source-ownership.md: 97daf3c430ba09c000eab947e159030568a7f89d +2026-08-04-configuration-source-ownership.zh.md: 424c47d36f47136669f4e02f980e63cabd203f9c diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 101c0e6ba4..97daf3c430 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -56,7 +56,7 @@ The line is that these take effect with no user action, before any turn, outside - A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; a deployment that must win against stored settings uses `--config-replace`. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. -- The adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. +- The LLM adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. The web-search providers still declare a `role('secret')` literal key; they register no settings namespace, so nothing can shadow a stored credential through them, but the claim is about the adapters rather than the repository as a whole. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index ad59f9a96e..424c47d36f 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -58,7 +58,7 @@ inherited process environment (read-only, wins) - 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;需要压过已存 settings 的部署方应使用 `--config-replace`。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 -- 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。 +- LLM 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。web-search 提供方仍声明 `role('secret')` 的字面密钥字段;它们不注册 settings namespace,因此无法借此遮蔽已存凭据,但这条声明的范围是适配器,而不是整个仓库。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml index eb74fbd0e2..376838d151 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md -2026-08-04-credentials-yaml-and-user-environment-layer.md: f1bca69820d03fe67849bd7c7159489ac27cd2e0 -2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7e6714abd33baad1fb2a570514754b467fcf8bd5 +2026-08-04-credentials-yaml-and-user-environment-layer.md: f03f3f885c13476619ba3cda51e2dfed7e3258c1 +2026-08-04-credentials-yaml-and-user-environment-layer.zh.md: 7cce1daeffadb18678f00a5c9acd1b14c6ac1b22 diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md index f1bca69820..f03f3f885c 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md @@ -34,7 +34,7 @@ There is no migration. The product is unreleased, and a key already in `$DSH_HOM - Given up: a key left in `$DSH_HOME/.env` is now hoisted into `process.env`, so it reaches subprocesses under the [subprocess credential scrub](../../../../packages/subprocess/subprocess/README.md) rather than staying inside the provider. That is the honest meaning of "ordinary environment layer"; a secret the Harness should own and isolate belongs in `.credentials.yaml`, which is never hoisted. - Given up: the same key shadows `.credentials.yaml` and makes the web Models page's write reject. The seam already reports `source: 'env', writable: false` for that state, and the rejection message now names the loaded `.env` as a place to unset it. - Bought: a non-secret in the user's `.env` finally takes effect, which was the original defect; the document format can reject what it cannot serve; and `0600` covers a file that holds only secrets instead of a file users are told to put ordinary configuration in. -- Not taken: a read-time permission check that fails startup when `.credentials.yaml` is more permissive than `0600`. Creation and atomic replacement already pin the mode; making a hand-created file fatal is a separable security decision. +- The `0600` the provider writes is also enforced on what it reads: on POSIX, a document with any group or other permission bit fails the launch before its contents are read, at boot and on every reload, and the diagnostic names the `chmod 600` repair. Windows has no mode to inspect — its ACLs are not expressible here — so the check is skipped rather than faked. - The `0600` boundary still stops other OS users and not the model, unchanged by this split — the [provider README](../../../../packages/credentials/credentials-local/README.md) owns that limit and the keychain-provider deferral. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md index 7e6714abd3..7cce1daeff 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.zh.md @@ -34,7 +34,7 @@ OPENAI_API_KEY: sk-… - 放弃的:留在 `$DSH_HOME/.env` 里的密钥现在会被提升进 `process.env`,因而会按[子进程凭据清洗](../../../../packages/subprocess/subprocess/README.md)的规则抵达子进程,而不再留在 provider 内部。这就是「普通环境层」的诚实含义;需要由 Harness 拥有并隔离的密钥属于 `.credentials.yaml`,后者永不提升。 - 放弃的:同一个键会遮蔽 `.credentials.yaml`,并让 Web Models 页的写入被拒。seam 对这种状态本来就报告 `source: 'env', writable: false`,而拒绝信息现在会把已加载的 `.env` 一并指为需要清除的位置。 - 换来的:用户 `.env` 里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;`0600` 保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 -- 未采纳的:在读取时校验权限、并在 `.credentials.yaml` 宽于 `0600` 时让启动失败。创建与原子替换已经钉住了模式;让手工创建的文件直接致命是一个可分离的安全决策。 +- provider 写入时用的 `0600` 同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。 - `0600` 这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 [provider README](../../../../packages/credentials/credentials-local/README.md) 所有。 ## Alternatives considered diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8cd2964da6..ca83c91965 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,7 +52,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | -| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 213841f58d..421a831362 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -22,8 +22,9 @@ # A `--config` overlay replaces this row's config to select exact GitHub # repository Plugin generations. The app registers the DSH-owned runtime even -# when the list is empty so a later personal-config edit can load -# transactionally; one-shot headless runs consume the startup value only. +# when the list is empty, so a `--config` overlay that supplies repositories +# needs no composition change here. Every surface reads that overlay once at +# startup. - id: repository-plugins name: '@deepseek-ai/dsh-repository-plugin' diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index ab4f195423..0265e3fe6a 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -95,7 +95,7 @@ export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. * @param config - an overlay of loader patches applied over the shipped web * composition, or `undefined` to boot the - * personal overlay; already parsed from `--config`. + * shipped Web composition; already parsed from `--config`. */ export async function runWeb( environment: EnvironmentSnapshot, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3646f58fb7..0d5c71648f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -682,8 +682,6 @@ export interface Config { /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ @@ -711,7 +709,7 @@ export interface PiAiProviderProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:62`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:60`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml index 23bb940afe..d44275d97e 100644 --- a/docs/core-data-structures/credentials.i18n.yaml +++ b/docs/core-data-structures/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/credentials.md -credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 -credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca +credentials.md: ef74ddeb4346e18f8d5d33488657e5d50f1d754e +credentials.zh.md: 09cf374a2346fd93aa834e3372321e6eeece6ed8 diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md index 3f6fcd127d..ef74ddeb43 100644 --- a/docs/core-data-structures/credentials.md +++ b/docs/core-data-structures/credentials.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md index b5d2d9e164..09cf374a23 100644 --- a/docs/core-data-structures/credentials.zh.md +++ b/docs/core-data-structures/credentials.zh.md @@ -24,7 +24,7 @@ type CredentialRef = Branded<'CredentialRef'> interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } ``` diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index fc89d359e8..729ae6f958 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md -README.md: ca2af9d8a514b43aeef19abec7cda4e44645bdaf -README.zh.md: a8be53629853fe6fb7c39ef2281ac798b5624010 +README.md: 45c18714c9ca81d98d2c18c385c772545e2e15d1 +README.zh.md: 59e0158980cede3eb3f5590b00f858ddffcee328 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index ca2af9d8a5..45c18714c9 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -2,14 +2,20 @@ English | [中文](README.zh.md) -File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. +File-backed [credentials](../credentials/README.md) provider: four layers, one honest precedence. | Layer | Source id | Writable | Wins | |---|---|---|---| -| Live process environment | `env` | no | always | -| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | otherwise | +| Inherited process environment | `env` | no | always | +| `$DSH_HOME/.credentials.yaml` document | `file` | yes (`set`/`unset`) | over both `.env` layers | +| `/.env` | `project-env` | not here | over the user `.env` | +| `$DSH_HOME/.env` | `user-env` | not here | otherwise | -The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. +The launching environment wins because a per-run override (`DEEPSEEK_API_KEY=… dsh`, a CI secret, a container `-e`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. + +Everything below it loses to the managed store, so a key written by the web page or TUI takes effect immediately even when an older key sits in a `.env`. Those two layers still resolve when nothing is stored, and `describe()` names them `project-env` or `user-env` with `writable: true` — storing a key replaces them as the effective source. + +Under the product CLI, resolution reads the launcher's frozen [environment snapshot](../../util/environment/README.md) rather than `process.env`: only the snapshot can say whether a value came from the launching shell or from a file. A composition the product CLI did not boot has the inherited environment as its only layer, which keeps embedders on the semantics they already had. ## Config @@ -35,13 +41,17 @@ Writes patch the parsed document rather than rebuilding it, so comments and the Any string value round-trips, multi-line values included, so no entry is unwritable for want of a quoting style. An empty stored value is absent, per the seam rule — which is why an empty string in the document is rejected outright: `unset` removes a key, it does not blank it. +## Permissions + +The provider creates the directory `0700` and creates or atomically replaces the document `0600`. It holds what it *reads* to that same bound: on POSIX a document carrying any group or other permission bit fails before its contents are parsed — at boot and on every reload — and the error names the `chmod 600` repair. Windows has no mode to inspect, so the check is skipped there rather than faked. + ## Hot reload External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)) — so reaching the value takes a deliberate read of a path the agent was not given. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment — unlike `$DSH_HOME/.env`, which is the user's ordinary environment layer (see [app-boot's Harness home](../../ui/app-boot/README.md#the-harness-home)) — so reaching the value takes a deliberate read of a path the agent was not given. That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. @@ -55,9 +65,7 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. - **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. - **A same-UID process can read the document** — see [Security boundary](#security-boundary): the file-effect sandbox modes do not deny reads, and an OS-keychain provider is deferred. -- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. -- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. +- **Environment changes are invisible** — the snapshot is frozen at launch, so a variable exported after startup reaches neither resolution nor `describe`; changing an environment-sourced credential takes a restart. - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index a8be536298..59e0158980 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,14 +2,20 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 +文件型[凭据](../credentials/README.md) provider:四层来源,一条诚实的优先级。 | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| -| 活跃进程环境 | `env` | 否 | 恒定优先 | -| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | +| 继承的进程环境 | `env` | 否 | 恒定优先 | +| `$DSH_HOME/.credentials.yaml` 文档 | `file` | 是(`set`/`unset`) | 高于两个 `.env` 层 | +| `/.env` | `project-env` | 不在此处 | 高于用户 `.env` | +| `$DSH_HOME/.env` | `user-env` | 不在此处 | 其余情况 | -环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +启动环境优先,因为按次覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、容器 `-e`)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。 + +它之下的一切都输给受管存储,因此 Web 页面或 TUI 写入的密钥会立即生效,即使某个 `.env` 里还留着更旧的密钥。没有存储任何东西时这两层仍会解析,`describe()` 会把来源报告为 `project-env` 或 `user-env` 且 `writable: true`——存入一个密钥就会取代它们成为生效来源。 + +在产品 CLI(命令行界面)下,解析读取的是启动器冻结的[环境快照](../../util/environment/README.md)而不是 `process.env`:只有快照才说得清某个值来自启动 shell 还是来自某个文件。并非由产品 CLI 启动的组合只有继承环境这一层,这让嵌入方保持它们原有的语义。 ## 配置 @@ -35,13 +41,17 @@ OPENAI_API_KEY: sk-… 任何字符串值都能往返,包括多行值,因此不会再有条目因为缺少可用引号样式而不可写。空的存储值等于不存在(seam 规则)——这也正是文档中的空字符串被直接拒绝的原因:`unset` 删除键,而不是把它置空。 +## 权限 + +provider 以 `0700` 创建目录,以 `0600` 创建或原子替换文档。它对*读取*同样守住这条界线:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在解析其内容之前失败——启动时与每次 reload 都检查——并在错误里给出 `chmod 600` 的修复命令。Windows 没有可检查的 mode,因此在那里跳过该检查而不是伪造它。 + ## 热重载 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则响亮失败。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))——因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home](../../ui/app-boot/README.md#the-harness-home))——因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 @@ -55,9 +65,7 @@ OPENAI_API_KEY: sk-… ## Known Limitations and Deferred Work -- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 - **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):文件效果沙箱模式不会拒绝读取,OS 钥匙串 provider 仍是延后项。 -- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 -- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 +- **环境变化不可见**:快照在启动时冻结,因此启动之后 export 的变量既不会进入解析,也不会进入 `describe`;要更换来自环境的凭据需要重启。 - **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 1f0f550c05..a5024353c8 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -40,7 +40,7 @@ import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' -import { Document, parseDocument } from 'yaml' +import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { environmentOf } from '@deepseek-ai/dsh-environment' @@ -128,10 +128,10 @@ function isENOENT(error: unknown): boolean { * @param error - the parser's error. * @returns the error code with its line and column. */ -function describeYamlError(error: { code?: string; linePos?: [{ line: number; col: number }, ...unknown[]] }): string { +function describeYamlError(error: YAMLError): string { const at = error.linePos?.[0] const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` - return `${error.code ?? 'YAML_ERROR'}${where}` + return `${error.code}${where}` } /** diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index b640b42881..c6470c1628 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -32,7 +32,7 @@ export function credentialRef(value: string): CredentialRef { export interface ResolvedCredential { /** The non-empty secret value. */ value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 45d9cee054..d456e9282e 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 020aa65073495526be3f32912b7cd06667c52a2e -README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb +README.md: b8619268fc264439184ad51d208996ebb3c64e66 +README.zh.md: 650185083e5b36bc8508bc3847f87bdf5e1c5678 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 020aa65073..b8619268fc 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -15,7 +15,6 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment - # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high @@ -53,7 +52,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. @@ -112,7 +111,6 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. -- **`Config.apiKey` is redacted on the wire but still a stored literal** — `describe({ redactSecrets: true })` strips it and reports the slot, so a configuration UI never receives the value; the key is nonetheless stored in the settings document rather than the credential store, so prefer `apiKeyEnv`. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4c655e90ba..650185083e 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -15,7 +15,6 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment - # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high @@ -53,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 @@ -77,7 +76,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -112,7 +111,6 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 -- **`Config.apiKey` 在协议上已脱敏,但仍是一个已存的字面值**:`describe({ redactSecrets: true })` 会把它剥离并报告该槽位,配置 UI 因此永远收不到该值;但这个密钥仍存放在 settings 文档而非凭据存储中,所以请优先使用 `apiKeyEnv`。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 153281afe3..f1127dbf57 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -109,7 +109,7 @@ describe('request-level dynamic configuration', () => { it('advertises a live settings catalog without re-registration', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) @@ -120,7 +120,7 @@ describe('request-level dynamic configuration', () => { it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) // Observing the topology event, not just the end state: disposing and // re-registering also lands on the right final registry, but publishes an @@ -145,7 +145,7 @@ describe('request-level dynamic configuration', () => { it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) // Schema-valid but resolver-invalid: duplicate catalog ids pass the array // schema and fail the explicit resolve step. diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index c635b1f13e..1e546b6e3a 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -20,8 +20,6 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ - apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ @@ -76,7 +74,6 @@ const thinkingBudgets = z.object({ }) const profile = z.object({ - apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), baseURL: z.string(), headers: z.dict(z.string()), @@ -126,9 +123,6 @@ export function resolveProfiles( } if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`) - if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) - } if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index c138b8f5fc..d5664b7cb2 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -89,7 +89,6 @@ export function apply(ctx: Context, config: Config): void { provider: string, profile: ResolvedPiAiProviderProfile, ): Promise => { - if (profile.apiKey !== undefined) return profile.apiKey const ref = profile.apiKeyEnv // Only a profile that names no credential at all defers to pi-ai's // provider-native discovery. Once one is named, a miss must fail loud: diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a0826b3571..daf9c517a4 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -15,22 +15,32 @@ afterEach(async () => { }) async function harness(baseURL: string, overrides: Record = {}): Promise { + vi.stubEnv('PI_TEST_KEY', 'test-key') const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } }, + providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } }, }) return ctx } -/** Direct adapter over the real profile resolver, with literal-key resolution. */ -function adapterOf(providers: Record): PiAiAdapter { +/** Direct adapter over the real profile resolver, with a fixed key per call. */ +function adapterOf( + providers: Record, + apiKey: string | undefined = 'test-key', +): PiAiAdapter { return new PiAiAdapter({ profiles: () => resolveProfiles(providers), - resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), + resolveApiKey: () => Promise.resolve(apiKey), }) } +beforeEach(() => { + // Configuration carries only the reference; these mounts resolve it from + // the environment, which is the whole credential plane without a seam. + vi.stubEnv('PI_TEST_KEY', 'test-key') +}) + describe('PiAiAdapter provider routing', () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => { const server = await mockServer([{ events: textEvents }]) @@ -117,7 +127,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) ctx.llm.registerAdapter(['deepseek'], adapterOf({ - deepseek: { apiKey: 'test-key', baseURL: server.url }, + deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url }, })) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -146,7 +156,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') @@ -166,7 +176,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) @@ -182,7 +192,7 @@ describe('PiAiAdapter provider routing', () => { await ctx.plugin(LlmPiAi, { providers: { openai: { - apiKey: 'test-key', + apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/api/projects/openai/openai/v1`, headers: { 'api-key': 'test-key', Authorization: '' }, }, @@ -372,7 +382,9 @@ describe('provider profile lifecycle', () => { it('accepts absent credentials for pi-ai ambient authentication', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) - const ctx = await harness(server.url, { apiKey: undefined }) + // A profile that names no reference at all is the one case that defers to + // pi-ai's own provider-native discovery. + const ctx = await harness(server.url, { apiKeyEnv: undefined }) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) @@ -410,8 +422,6 @@ describe('provider profile lifecycle', () => { // loud with migration directions instead of half-working. expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/) - expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/) - expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/) expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/) expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/) }) @@ -486,7 +496,7 @@ describe('abort wiring', () => { const message = Object.defineProperty({}, 'role', { get() { throw original }, }) - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -507,7 +517,7 @@ describe('abort wiring', () => { throw original }, }) - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -521,7 +531,7 @@ describe('abort wiring', () => { }) it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) + const adapter = adapterOf({ deepseek: {} }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index cc5cd17e55..2c8b07a2aa 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -53,7 +53,11 @@ describe('request-level dynamic profiles', () => { it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-from-settings\n', { mode: 0o600 }) + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }]) // The exact product posture: `- id: llm-pi-ai` with no config at all. const ctx = await boot(dir, {}) @@ -86,14 +90,19 @@ describe('request-level dynamic profiles', () => { it('adds a provider route from settings and drops it when the user layer resets', async () => { const dir = await home() + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }]) const ctx = await boot(dir, { - providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, + providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } }, }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) await ctx.settings.update(NS, { - providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } }, + providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } }, }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) @@ -158,15 +167,20 @@ describe('request-level dynamic profiles', () => { it('keeps serving its routes when a settings-born route collides with another adapter', async () => { const dir = await home() + await writeFile( + join(dir, '.credentials.yaml'), + 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + { mode: 0o600 }, + ) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) - const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } }) + const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } }) // Another adapter owns `anthropic`; the registry must refuse to hand it over. ctx.llm.registerAdapter(['anthropic'], new StubAdapter()) await ctx.settings.update(NS, { providers: { - openai: { apiKey: 'pk', baseURL: `${server.url}/v1` }, - anthropic: { apiKey: 'other' }, + openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` }, + anthropic: { apiKeyEnv: 'PI_OTHER_KEY' }, }, }) diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 3f12ef4460..a2727de75f 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -23,7 +23,7 @@ describe('pi-ai SDK retry boundary', () => { }, }) const adapter = new PiAiAdapter({ - profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + profiles: () => resolveProfiles({ openai: {} }), resolveApiKey: () => Promise.resolve('test-key'), }) const drain = async (): Promise => { diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index fc4f173263..b978bd33bb 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -27,7 +27,6 @@ ], "license": "BSD-3-Clause", "dependencies": { - "dotenv": "^17.2.0", "js-yaml": "^4.2.0" }, "peerDependencies": { diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 0f3cbd6687..99220e4269 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -6,10 +6,10 @@ * @module @deepseek-ai/dsh-app-boot */ +import { parseEnv } from 'node:util' import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { basename, dirname, resolve } from 'node:path' -import { parse as parseDotenv } from 'dotenv' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' @@ -94,7 +94,13 @@ function readEnvLayer( // ENOENT (no .env) is fine — rely on the ambient environment. return undefined } - const values = parseDotenv(content) + // `node:util`'s parseEnv is the same parser `--env-file` and + // `process.loadEnvFile` use. Checking with a second dialect (npm dotenv) + // would leave the rejection rule and the thing it guards on independently + // maintained parsers: a name Node accepts but the checker does not would + // reach `process.env` unchecked, and `BASH_ENV` there runs a file of the + // project's choosing on every `bash -c` the bash tool issues. + const values = parseEnv(content) as Record for (const name of Object.keys(values)) { if (!isBootstrapOnly(name)) continue throw new Error( @@ -112,9 +118,11 @@ function readEnvLayer( * over the Harness home's `.env`, both under the inherited process * environment. * - * Each layer is parsed and checked before anything is applied, then applied in - * the order that makes the layering `user < project < inherited` — - * `process.loadEnvFile` never replaces a name already set. Values do reach + * Each layer is parsed once, checked, and only then applied — never replacing + * a name already set, which is what makes the layering `user < project < + * inherited`. The single parse is deliberate: the rejection rule and the + * values that reach `process.env` must come from the same parser, or a name + * one dialect accepts and the other misses would slip past the check. Values do reach * `process.env`, because a user's own `--config` tree and third-party * libraries read it; the returned snapshot is the authority for everything the * harness itself resolves, since `process.env` alone cannot say whether a @@ -144,8 +152,18 @@ export function loadLayeredEnv( // Parse both layers first: a rejection must not leave one file applied. const project = readEnvLayer(binName, cwd, warn) const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) - if (project !== undefined) process.loadEnvFile(project.path) - if (user !== undefined) process.loadEnvFile(user.path) + // Assign the entries this function already parsed and checked, rather than + // re-reading each file through `process.loadEnvFile`. One parse means the + // snapshot, the rejection rule, and `process.env` can never disagree about + // what a file contains. Skipping names already set reproduces the + // never-replace behavior that makes the layering `user < project < + // inherited`. + for (const layer of [project, user]) { + if (layer === undefined) continue + for (const [name, value] of Object.entries(layer.values)) { + if (process.env[name] === undefined) process.env[name] = value + } + } return createEnvironmentSnapshot([ { source: 'process', values: inherited }, ...project === undefined ? [] : [{ source: 'project-env' as const, path: project.path, values: project.values }], diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 6e27656805..11014f64b5 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -69,6 +69,16 @@ export interface EnvironmentSnapshot { readonly layers: readonly EnvironmentLayer[] } +/** + * The map key one variable name resolves under. Windows treats environment + * names case-insensitively; every other platform does not. + * @param name - the variable name as written. + * @returns the key to store and look up by. + */ +function lookupKey(name: string): string { + return process.platform === 'win32' ? name.toUpperCase() : name +} + /** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */ export interface EnvironmentLayerInput { source: EnvironmentSource @@ -84,18 +94,24 @@ export interface EnvironmentLayerInput { */ export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot { // Copied per layer so a later mutation of `process.env` — or of a caller's - // own object — cannot change what this snapshot reports. + // own object — cannot change what this snapshot reports. Windows environment + // names are case-insensitive, so lookups there fold case: otherwise a shell + // that set `deepseek_api_key` would be invisible to a consumer asking for + // `DEEPSEEK_API_KEY`, and a lower-ranked layer spelling it in caps would win + // a decision the launch had already made. POSIX names are case-sensitive and + // must stay exact. const bySource = new Map }>() for (const layer of layers) { bySource.set(layer.source, { ...layer.path === undefined ? {} : { path: layer.path }, - values: new Map(Object.entries(layer.values)), + values: new Map(Object.entries(layer.values).map(([name, value]) => [lookupKey(name), value])), }) } const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => { + const key = lookupKey(name) for (const source of sources) { const layer = bySource.get(source) - const value = layer?.values.get(name) + const value = layer?.values.get(key) if (value === undefined) continue return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } } } @@ -154,13 +170,21 @@ const BOOTSTRAP_NAMES = new Set([ 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', - // Version-control hooks that run a command on the setter's behalf. + 'PYTHONHOME', + // Version-control hooks that run a command on the setter's behalf, and the + // config redirections that can define such a hook indirectly (a substituted + // git config file can set core.pager or a credential helper). 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', + 'GIT_ASKPASS', 'SSH_ASKPASS', + 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', 'EDITOR', 'VISUAL', 'PAGER', // Network reach and trust. 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', + // Turns off TLS verification outright, which is the sharpest form of + // "how the network is trusted". + 'NODE_TLS_REJECT_UNAUTHORIZED', ]) /** Name prefixes no discovered file may set. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18b62cae80..adeea9a056 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5657,9 +5657,6 @@ importers: packages/ui/app-boot: dependencies: - dotenv: - specifier: ^17.2.0 - version: 17.4.2 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -9752,10 +9749,6 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -14843,8 +14836,6 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dotenv@17.4.2: {} - dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 2f35e58d43..318bda59b0 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -13,13 +13,12 @@ workspaceContext: maxBytes: 65536 -# Stock DeepSeek adapters. Loading requires an API key; initialize and shutdown -# may use a dummy key because they do not call the model. +# Stock DeepSeek adapters. The adapter resolves DEEPSEEK_API_KEY through the +# credential seam and, with no provider mounted here, from the launching +# environment; DEEPSEEK_BASE_URL follows the same environment ladder. Neither +# is inlined, so this file names no secret and no route. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL # JSONL persistence; $DSH_SESSION_ROOT wins over ./.sessions in the process cwd. - id: sessions diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index d346b19233..8b59957c4b 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -72,9 +72,21 @@ const ENV_READ_ALLOWLIST: Readonly> = { } /** Shipped Cordis configuration these rules apply to. */ -const SHIPPED_CONFIG_GLOBS = ['apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml'] +const SHIPPED_CONFIG_GLOBS = [ + 'apps/*/config/*.yml', + 'examples/*/*.cordis.yml', + 'examples/*/cordis.yml', + // The Python runtime ships its own default composition inside the wheel. + 'python/*/src/**/cordis.yml', +] -/** Config keys that must never be inlined from the environment. */ +/** + * Config keys that must never be inlined from the environment. Line-anchored + * on purpose: this is a tripwire for the shape people actually write, not a + * YAML analysis. A folded scalar or a block-literal spelling would slip past + * it, which is acceptable because the rule it guards is also stated in the + * owning Agent Note and enforced by the adapters' own resolution. + */ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ const failures: string[] = [] From 286f356942207ac60b6a898188d8d90f1316814b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 11:29:36 +0800 Subject: [PATCH 09/88] docs: narrow the composition claims to what survived the TUI removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master's #1369 deleted the TUI, the meta and upgrade subcommands, and the whole-tree --config-replace path. These notes were written before that landed and still promised a flag the CLI no longer registers, and named it as the lever a deployment uses to pin a field against a user's stored settings — which now has no CLI equivalent at all. State what shipped: every booting surface takes --config, dsh -p is the surface this change actually gave it to, and a deployment that must win against stored settings ships its own bin or loader tree. Each note cross-links #1369's own note rather than restating the removal, and the shared-base note moves its --config-replace sentences to past tense. --- .../2026-08-04-configuration-source-ownership.i18n.yaml | 4 ++-- .../2026-08-04-configuration-source-ownership.md | 6 +++--- .../2026-08-04-configuration-source-ownership.zh.md | 6 +++--- .../2026-07-29-shared-base-config-overlays.i18n.yaml | 4 ++-- .../2026-07-29-shared-base-config-overlays.md | 2 +- .../2026-07-29-shared-base-config-overlays.zh.md | 2 +- ...2026-08-04-remove-personal-composition-layer.i18n.yaml | 4 ++-- .../2026-08-04-remove-personal-composition-layer.md | 8 ++++---- .../2026-08-04-remove-personal-composition-layer.zh.md | 8 ++++---- .../2026-08-04-remove-profile-json-entry.i18n.yaml | 4 ++-- .../2026-08-04-remove-profile-json-entry.md | 2 +- .../2026-08-04-remove-profile-json-entry.zh.md | 2 +- 12 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index cbce8a65e8..51d58cd442 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 97daf3c430ba09c000eab947e159030568a7f89d -2026-08-04-configuration-source-ownership.zh.md: 424c47d36f47136669f4e02f980e63cabd203f9c +2026-08-04-configuration-source-ownership.md: 7f8dba2e4879fee34c4526bd73436b4c8ddd13aa +2026-08-04-configuration-source-ownership.zh.md: 26fdad39887c07fe420e1c37d49b252eeeb2e3ae diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 97daf3c430..7f8dba2e48 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -21,13 +21,13 @@ And `!!js process.env.X` in the shipped composition made the same value reachabl ```text explicit for this run per-operation override, CLI argument > user settings settings.yaml -> composition --config / --config-replace, shipped base +> composition --config overlay, shipped base > this launch's shell inherited process environment > discovered file $DSH_HOME/.env > defaults schema default, provider public default ``` -Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value the shipped base set from one a `--config` overlay set — both arrive as entry config. A deployment that must pin a field against a user's stored settings therefore uses `--config-replace`, which bypasses the tree the settings base is derived from. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint. +Settings sit above composition because that is what the [settings seam](2026-07-28-user-settings-seam.md) does: a plugin registers its cordis entry config as the `base` layer and the user's section layers over it, and the seam cannot tell a value the shipped base set from one a `--config` overlay set — both arrive as entry config. The product CLI has no lever above stored settings: `--config-replace` was removed with the TUI ([explicit-config entrypoint](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)), so a deployment that must pin a field against a user's settings ships its own bin or loader tree, or mounts no settings provider at all. Composition still outranks the environment, so a stale `DEEPSEEK_BASE_URL` in a shell cannot rewrite a configured endpoint. **Credentials keep a narrower, separate ordering**, and this note does not unify them: @@ -54,7 +54,7 @@ The line is that these take effect with no user action, before any turn, outside - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. - A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. -- `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; a deployment that must win against stored settings uses `--config-replace`. +- `--config` is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all, which closes the escalation path; a project `.env` setting something like `GIT_SSH_COMMAND` for the tools an agent runs remains possible and is recorded as a limitation on the package. - The LLM adapters no longer accept a literal `apiKey`: configuration carries the reference and nothing else, so a settings document cannot become a second credential store. No adapter namespace is strict, so writing one is dropped rather than rejected. The web-search providers still declare a `role('secret')` literal key; they register no settings namespace, so nothing can shadow a stored credential through them, but the claim is about the adapters rather than the repository as a whole. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request seam resolution is separate work. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 424c47d36f..26fdad3988 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -21,7 +21,7 @@ endpoint 可以被项目重定向。调用目录的 `.env` 和其他层一样会 ```text explicit for this run per-operation override, CLI argument > user settings settings.yaml -> composition --config / --config-replace, shipped base +> composition --config overlay, shipped base > this launch's shell inherited process environment > discovered file $DSH_HOME/.env > defaults schema default, provider public default @@ -29,7 +29,7 @@ explicit for this run per-operation override, CLI argument 自上而下依次是:本次运行的显式意图、用户 settings、composition、本次启动的 shell、被发现的文件、默认值。 -settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是交付基座设的还是 `--config` overlay 设的——两者都以 entry config 的形式抵达。因此,需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应使用 `--config-replace`,它绕过了 settings base 所派生的那棵树。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。 +settings 在 composition 之上,因为 [settings seam](2026-07-28-user-settings-seam.md) 就是这么做的:插件把自己的 cordis entry config 注册为 `base` 层,用户 section 叠加其上,而 seam 无法区分某个值是交付基座设的还是 `--config` overlay 设的——两者都以 entry config 的形式抵达。产品 CLI(命令行界面)没有高于已存 settings 的手段:`--config-replace` 已随 TUI 一并移除(见[显式配置入口](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)),因此需要把某字段钉死、不被用户已存 settings 覆盖的部署方,应自带 bin 或 loader 配置树,或者干脆不挂载 settings provider。composition 仍然高于环境,所以 shell 里陈旧的 `DEEPSEEK_BASE_URL` 无法改写已配置的 endpoint。 **凭据保留一条更窄的独立顺序**,本 Note 不把它并入上表: @@ -56,7 +56,7 @@ inherited process environment (read-only, wins) - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 - 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 -- `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;需要压过已存 settings 的部署方应使用 `--config-replace`。 +- `--config` 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件,提权路径已封闭;项目 `.env` 为 agent 运行的工具设置诸如 `GIT_SSH_COMMAND` 之类的变量仍然可能,已作为限制记录在该包上。 - LLM 适配器不再接受字面 `apiKey`:配置只携带引用,因此 settings 文档无法成为第二个凭据存储。由于没有任何适配器 namespace 是 strict 的,写入该键会被 schema 丢弃而不是报错。web-search 提供方仍声明 `role('secret')` 的字面密钥字段;它们不注册 settings namespace,因此无法借此遮蔽已存凭据,但这条声明的范围是适配器,而不是整个仓库。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求经 seam 解析是另一件事。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index 37df0f897d..0dfa674535 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: 80418447cf45f9f4aa279d1b46b5181d383d0a12 -2026-07-29-shared-base-config-overlays.zh.md: c75dd66c8fb299d4f2a57f7e9ea1acb54a2f8951 +2026-07-29-shared-base-config-overlays.md: 8e83282ed7ea2d3264f38bee8c29f72d0288aed5 +2026-07-29-shared-base-config-overlays.zh.md: bc2be5d74f57df7e15a4c7170a1162398229df5d diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index 80418447cf..8e83282ed7 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -20,7 +20,7 @@ One shared base, one overlay per surface, composed as sibling patch lists. Precedence is list order, last write winning per row: base, then the surface overlay, then a `--config` overlay, then the launcher's own flag patches. The personal `~/.dsh/config.yaml` sat in the `--config` slot until it was [removed with the personal composition layer](../simplification/2026-08-04-remove-personal-composition-layer.md). -`--config ` applies an overlay over the shipped tree (at the time, **instead of** the personal overlay, so a demo or test tree never inherited the user's provider and model). `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. +`--config ` applies an overlay over the shipped tree (at the time, **instead of** the personal overlay, so a demo or test tree never inherited the user's provider and model). `--config-replace ` booted a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survived the `/resume` execve handoff, or resuming would silently have changed the agent. That flag and the resume handoff were later removed with the TUI ([explicit-config entrypoint](2026-08-03-explicit-config-dsh-entrypoint.md)). A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index c75dd66c8f..bc2be5d74f 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -20,7 +20,7 @@ Status: implemented 优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay,最后是启动器自身的 flag patch。个人 `~/.dsh/config.yaml` 曾占据 `--config` 这一槽位,直到它[已随个人 composition 层一并删除](../simplification/2026-08-04-remove-personal-composition-layer.md)。 -`--config ` 在已交付配置树上应用一个 overlay(当时是**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model)。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 +`--config ` 在已交付配置树上应用一个 overlay(当时是**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model)。`--config-replace ` 当时把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 当时都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。该标志与 resume 交接后来随 TUI 一并移除(见[显式配置入口](2026-08-03-explicit-config-dsh-entrypoint.md))。 patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml index 11239d3c23..e000e463b7 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md -2026-08-04-remove-personal-composition-layer.md: 941e2248e15e235037e6bd48dcb3ba6c80bd83dd -2026-08-04-remove-personal-composition-layer.zh.md: 6c6f3ecd541590368624f4ed4bd409321a2f9772 +2026-08-04-remove-personal-composition-layer.md: e41109d4c141f55e511e102f99e87ef5c696ac47 +2026-08-04-remove-personal-composition-layer.zh.md: b5e47e188db6dfccb55b2800329a2f2e6cd2787f diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md index 941e2248e1..e41109d4c1 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.md @@ -12,17 +12,17 @@ A patch replaces its target row's whole `config`, so a personal file written mon It also competed with typed settings for the same values. `llm-deepseek` and `llm-pi-ai` register settings namespaces, and the same fields are reachable by patching their rows — so which one wins is a function of layer order, not of what the value means. That is the ownership ambiguity the [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) exists to remove. -Finally the escape hatch it was supposed to be redundant with did not cover every surface: `dsh -p`, `dsh meta`, and `dsh upgrade` all rejected `--config`. For those surfaces the implicit file was not one composition route among two — it was the only one. +Finally the escape hatch it was supposed to be redundant with did not cover every surface: `dsh -p` rejected `--config`, and so did the `meta` and `upgrade` subcommands of the time. For those surfaces the implicit file was not one composition route among two — it was the only one. ## Decision The implicit layer is deleted and the explicit one is completed. -**Every booting surface takes `--config` and `--config-replace`.** `dsh -p`, `dsh meta`, and `dsh upgrade` join the TUI, so naming a tree is available wherever a tree boots. A headless `--config-replace` tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; `AppCLIEntry` now names that contract in the failure instead of reporting a bare missing service. +**Every booting surface takes `--config`.** `dsh -p` joins the surfaces that already had it, so naming an overlay is available wherever a tree boots. The TUI, `meta`, and `upgrade` were removed in parallel by the [explicit-config entrypoint](2026-08-03-explicit-config-dsh-entrypoint.md), which also deleted the whole-tree `--config-replace` path; what remains of this change on that side is headless, which previously rejected the flag and had the implicit file as its only composition route. **`$DSH_HOME/config.yaml` is not read, watched, or dumped.** `PERSONAL_CONFIG_FILENAME`, `loadPersonalPatches`, `watchPersonalPatches`, and the config-only HMR row mounted for it are deleted. A file left at that path is inert. The Harness home keeps `settings.yaml`, `.credentials.yaml`, and `.env`; an overlay may still live there, but as a path to name, not a layer to discover. -`--config` therefore changes meaning slightly: it used to *replace* the personal overlay, and now it simply *is* the user overlay. `--config-replace` is unchanged. +`--config` therefore changes meaning slightly: it used to *replace* the personal overlay, and now it simply *is* the user overlay. Everyday capabilities keep their owners. Model and provider parameters already belong to the adapters' typed settings namespaces. The `repository-plugins` row ships mounted with an empty list, so a repository Plugin list is a `--config` overlay today and a settings namespace when one lands. MCP servers stay a `--config` composition, which is what [the CLI README](../../../../apps/cli/README.md) now documents. @@ -44,4 +44,4 @@ There is no migration and no deprecation diagnostic: the product is unreleased, **Delete it only after the settings-driven repository and MCP managers exist.** Rejected as an unnecessary dependency once `--config` reached every surface: the managers make those two cases *nicer*, but with the flag available everywhere, nothing is lost by removing the implicit layer first. -**Keep it for `dsh -p` alone, where no flag existed.** Rejected: that is the surface with the strongest case for explicitness. A CI or scripted run should name its composition rather than inherit whatever the machine holds. +**Keep it for `dsh -p` alone, where no flag existed.** Rejected: that is the surface with the strongest case for explicitness. A CI or scripted run should name its composition rather than inherit whatever the machine holds — which is why `-p` gained `--config` here instead. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md index 6c6f3ecd54..b5e47e188d 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-personal-composition-layer.zh.md @@ -12,17 +12,17 @@ patch 会替换目标行的整个 `config`,因此几个月前写下的个人 它还在同一批值上与类型化 settings 争夺所有权。`llm-deepseek` 与 `llm-pi-ai` 都注册了 settings namespace,而同样的字段也能通过 patch 它们的行抵达——于是谁赢取决于层序,而不取决于这个值的语义。这正是 [user-settings seam](../architecture/2026-07-28-user-settings-seam.md) 要消除的所有权歧义。 -最后,本应与它互为冗余的那条显式通道并未覆盖所有界面:`dsh -p`、`dsh meta` 和 `dsh upgrade` 都拒绝 `--config`。对这些界面来说,隐式文件不是两条 composition 路径之一——它是唯一的一条。 +最后,本应与它互为冗余的那条显式通道并未覆盖所有界面:`dsh -p` 拒绝 `--config`,当时的 `meta` 与 `upgrade` 子命令同样如此。对这些界面来说,隐式文件不是两条 composition 路径之一——它是唯一的一条。 ## Decision 删掉隐式的那一层,并把显式的那一层补完整。 -**每个会启动的界面都接受 `--config` 与 `--config-replace`。** `dsh -p`、`dsh meta` 和 `dsh upgrade` 与 TUI 看齐,因此只要有配置树启动的地方,就能点名一棵树。无头模式下的 `--config-replace` 树仍必须挂载 webserver 行,因为该界面是通过浏览器所用的同一个 HTTP 网关访问自己的 agent 的;`AppCLIEntry` 现在会在失败信息里说明这条契约,而不是只报告某个服务缺失。 +**每个会启动的界面都接受 `--config`。** `dsh -p` 与本来就有该标志的界面看齐,因此只要有配置树启动的地方,就能点名一份 overlay。TUI、`meta` 与 `upgrade` 由[显式配置入口](2026-08-03-explicit-config-dsh-entrypoint.md)并行移除,它同时删除了整棵树的 `--config-replace` 路径;本次变更在这一侧留下的就是 headless——它此前拒绝该标志,隐式文件是它唯一的 composition 路径。 **`$DSH_HOME/config.yaml` 不再被读取、监视或 dump。** `PERSONAL_CONFIG_FILENAME`、`loadPersonalPatches`、`watchPersonalPatches`,以及专为它挂载的那一行 config-only HMR,全部删除。留在该路径上的文件是惰性的。Harness home 仍然保有 `settings.yaml`、`.credentials.yaml` 和 `.env`;overlay 也仍然可以放在那里,但它是一条待点名的路径,而不是一层待发现的配置。 -因此 `--config` 的含义略有变化:它过去是*替代*个人 overlay,现在它本身*就是*用户 overlay。`--config-replace` 保持不变。 +因此 `--config` 的含义略有变化:它过去是*替代*个人 overlay,现在它本身*就是*用户 overlay。 日常能力各自保有归属。模型与 provider 参数已经属于各适配器的类型化 settings namespace。`repository-plugins` 行随交付配置以空列表挂载,因此仓库插件列表今天是一个 `--config` overlay,等 settings namespace 落地后归它。MCP 服务器仍然是 `--config` composition,这也是 [CLI README](../../../../apps/cli/README.md) 现在的写法。 @@ -44,4 +44,4 @@ patch 会替换目标行的整个 `config`,因此几个月前写下的个人 **等 settings 驱动的 repository 与 MCP manager 落地后再删。** 在 `--config` 覆盖所有界面之后,这条依赖已无必要,故否决:那两个 manager 会让这两种场景*更好用*,但只要标志处处可用,先删掉隐式层就不损失任何东西。 -**只为 `dsh -p` 保留它,因为那里原本没有标志。** 否决:那恰恰是最需要显式的界面。CI 或脚本化运行应当点名自己的 composition,而不是继承机器上恰好存在的东西。 +**只为 `dsh -p` 保留它,因为那里原本没有标志。** 否决:那恰恰是最需要显式的界面。CI 或脚本化运行应当点名自己的 composition,而不是继承机器上恰好存在的东西——所以这里改为给 `-p` 补上 `--config`。 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml index 60bfb506ae..5059240ce9 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md -2026-08-04-remove-profile-json-entry.md: 8ca81e2364e095d90c87febfe705ddec14269bf4 -2026-08-04-remove-profile-json-entry.zh.md: bbc3957d11a2051e7c1f9eaaed52d8af38fa1e5b +2026-08-04-remove-profile-json-entry.md: 90d90adc8c4a6828f3ce49253150d09527a8304a +2026-08-04-remove-profile-json-entry.zh.md: 60646a0ffc76ec967fef57f54ff0865b3c842754 diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md index 8ca81e2364..90d90adc8c 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.md @@ -12,7 +12,7 @@ Meanwhile the fields it mapped acquired owners elsewhere. `provider` and `model` ## Decision -`PROFILE_DIR`, `PROFILE_FILE`, `ProfileMapping`, `PROFILE_MAPPINGS`, and `readProfile()` are deleted along with the patch source that consumed them. `AppCLIEntry` composes its patches from CLI flags and the resolved frontend `distIndex` only; the layers around it — shipped base, surface overlay, `--config` or the personal overlay, and `--config-replace` — are unchanged. +`PROFILE_DIR`, `PROFILE_FILE`, `ProfileMapping`, `PROFILE_MAPPINGS`, and `readProfile()` are deleted along with the patch source that consumed them. `AppCLIEntry` composes its patches from CLI flags and the resolved frontend `distIndex` only; the layers around it — shipped base, surface overlay, and the `--config` overlay — are unchanged. A `.dsh-tmp-profile/config.json` on disk is now ignored completely. There is no migration, no replacement format, and no deprecation diagnostic: the file never had a producer, so there is no installed base to carry forward, and the [pre-release stance](../../../../AGENTS.md) rejects compatibility shims. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md index bbc3957d11..60646a0ffc 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-profile-json-entry.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`PROFILE_DIR`、`PROFILE_FILE`、`ProfileMapping`、`PROFILE_MAPPINGS` 和 `readProfile()` 连同消费它们的那个 patch 来源一并删除。`AppCLIEntry` 现在只从 CLI 标志和解析出的前端 `distIndex` 合成 patch;它周围的各层——交付基座、surface overlay、`--config` 或个人 overlay、以及 `--config-replace`——保持不变。 +`PROFILE_DIR`、`PROFILE_FILE`、`ProfileMapping`、`PROFILE_MAPPINGS` 和 `readProfile()` 连同消费它们的那个 patch 来源一并删除。`AppCLIEntry` 现在只从 CLI 标志和解析出的前端 `distIndex` 合成 patch;它周围的各层——交付基座、surface overlay、以及 `--config` overlay——保持不变。 磁盘上的 `.dsh-tmp-profile/config.json` 现在被完全忽略。没有迁移、没有替代格式、也没有弃用诊断:该文件从来没有生产方,因此不存在需要承接的存量,而[未发布阶段的立场](../../../../AGENTS.md)拒绝兼容垫片。 From 84b119619ae3ad5482cd36eb874e728ea1a9b1e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 12:42:06 +0800 Subject: [PATCH 10/88] chore(environment): match the tightened published-files constraint Master narrowed `files` to the built entrypoints plus declarations; the new environment package still carried declaration maps and `src`. --- packages/util/environment/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index 94a2a76ef6..6029a9f52a 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { From 2c47636a85b2ac4dc38c399a58b2923456913ce3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 12:45:18 +0800 Subject: [PATCH 11/88] docs(environment): state the snapshot's name-matching contract The Windows case-folding in the lookup was implemented without a user-facing contract. Name matching follows the platform, and the reason it must is the layer ranking it would otherwise invert. --- packages/util/environment/README.i18n.yaml | 4 ++-- packages/util/environment/README.md | 2 ++ packages/util/environment/README.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index c7ad354478..ea1e025257 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/environment/README.md -README.md: 526c7263106962cdbc19ec58c00b06e58849a258 -README.zh.md: 203b8252d2e96235ec083481ccafda129902cd38 +README.md: 1bb444bc217ce1a01fb98f954d6e1c2bbc3db957 +README.zh.md: a46adf0beeb0fb2069e198c99e4c00c2e8c09c6c diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index 526c726310..1bb444bc21 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -18,6 +18,8 @@ Values do also reach `process.env` — a user's `--config` tree and third-party **Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true. +Names match the way the platform matches them: exactly on POSIX, case-insensitively on Windows. A case-sensitive lookup there would rank the wrong layer — a shell's `deepseek_api_key` and a project `.env`'s `DEEPSEEK_API_KEY` are one variable to the OS, and treating them as two would let the project win. + ```ts import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index 203b8252d2..a46adf0bee 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -18,6 +18,8 @@ **省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去,后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。 +变量名按平台自身的规则匹配:POSIX 上精确匹配,Windows 上不区分大小写。在 Windows 上做大小写敏感的查找会选错层——shell 里的 `deepseek_api_key` 与项目 `.env` 里的 `DEEPSEEK_API_KEY` 对操作系统而言是同一个变量,把它们当成两个就会让项目胜出。 + ```ts import type { Context } from 'cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' From f50b60390c539a979ca69713ab92ae72682a4c8c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 12:49:05 +0800 Subject: [PATCH 12/88] docs: regenerate the module graph for the environment package `dsh-environment` and its consumer edges were missing from the generated graph. --- docs/module-graph.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index aae611eff5..344d1f7131 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_environment["environment"] pkg_native_command["native-command"] pkg_paths["paths"] pkg_retention["retention"] @@ -275,6 +276,7 @@ flowchart TD end pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants + pkg_environment --> pkg_invariants pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants @@ -345,11 +347,13 @@ flowchart TD pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings @@ -402,6 +406,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand @@ -435,8 +440,10 @@ flowchart TD pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web + pkg_web_search_exa --> pkg_environment pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_environment pkg_web_search_perplexity --> pkg_invariants pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand @@ -449,6 +456,7 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_app_boot --> pkg_environment pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt @@ -520,6 +528,7 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_environment pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web @@ -1078,6 +1087,7 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`environment`](../packages/util/environment) | `util` | [`invariants`](../packages/support/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | @@ -1119,8 +1129,8 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1131,7 +1141,7 @@ flowchart TD | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | @@ -1141,12 +1151,12 @@ flowchart TD | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | +| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1163,7 +1173,7 @@ flowchart TD | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | From 69d8621e2e060ab158467809b47a0841a976ecbe Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 13:20:56 +0800 Subject: [PATCH 13/88] test: close the per-file coverage gaps this PR opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layered-env reader gained an unreadable-layer path, a default reporter, and two absent-layer arms with no cases; the credential store gained two error paths that must not be mistaken for an absent file. The platform arms and the `linePos` guard cannot be reached from a POSIX test run — the first is covered by the native Windows job, the second only satisfies an optional type that `prettyErrors` always fills — so both carry a v8 ignore naming why. --- .../credentials-local/src/index.ts | 2 + .../credentials-local/tests/local.spec.ts | 23 ++++ packages/settings/settings-local/src/index.ts | 1 + packages/ui/app-boot/tests/app-boot.spec.ts | 107 ++++++++++++++++++ packages/util/environment/src/index.ts | 1 + 5 files changed, 134 insertions(+) diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index a5024353c8..ea77458d12 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -101,6 +101,7 @@ const GROUP_OTHER_BITS = 0o077 * @throws when the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { + /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ if (process.platform === 'win32') return let mode: number try { @@ -130,6 +131,7 @@ function isENOENT(error: unknown): boolean { */ function describeYamlError(error: YAMLError): string { const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` return `${error.code}${where}` } diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 7a8b8fdc17..43e42cd53f 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -179,6 +179,29 @@ describe('layer ladder', () => { .rejects.toThrow(/readable beyond its owner \(mode 644\)/) }) + it('propagates a permission check that fails for a reason other than absence', async () => { + const dir = await tempDir() + const notADirectory = join(dir, 'occupied') + await writeFile(notADirectory, 'a regular file\n') + // An absent document is an empty store, but a path that cannot be + // reached at all is a misconfiguration: the parent is a file, so the + // check fails with ENOTDIR rather than concluding "no credentials yet". + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path: join(notADirectory, '.credentials.yaml'), watch: false })) + .rejects.toThrow(/ENOTDIR/) + }) + + it('propagates a read that fails for a reason other than absence', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + // Owner-only, so the permission check passes, and unreadable as a file: + // the store is present but cannot be parsed, which must fail the launch + // rather than silently serve nothing. + await mkdir(path, { mode: 0o700 }) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(/EISDIR/) + }) + it('lets only the inherited environment shadow the store, read-only', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index d713083c20..142d7935bd 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -250,6 +250,7 @@ export class SettingsLocal extends Settings { throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ document.errors.map((error) => { const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}` }).join('; ')}`) } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index fba1ad1993..4d44c780c7 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -190,6 +190,113 @@ describe('loadLayeredEnv', () => { vi.unstubAllEnvs() } }) + + it('warns and continues when a layer exists but cannot be read', () => { + const home = tmp() + const project = tmp() + // A directory named `.env` is present-but-unreadable (EISDIR): unlike an + // absent file, it is a real misconfiguration, so it is reported rather + // than passed over in silence — and the other layers still load. + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reports to stderr when the caller supplies no reporter', () => { + const home = tmp() + const project = tmp() + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const snapshot = loadLayeredEnv(NAME, project) + expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + write.mockRestore() + clear() + vi.unstubAllEnvs() + } + }) + + it('passes over an absent layer without reporting it', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + // No user `.env` exists, which is ordinary rather than a fault: the + // layer is simply absent, and nothing is reported. + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).not.toHaveBeenCalled() + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('carries only the inherited environment when neither file exists', () => { + const home = tmp() + const project = tmp() + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.layers).toEqual([{ source: 'process' }]) + expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reads a harness home that is also the invocation directory exactly once', () => { + const both = tmp() + writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`) + clear() + vi.stubEnv('DSH_HOME', both) + try { + // One file cannot be two layers. It is the project layer, because that + // is the more trusted of the two — reading it twice would otherwise + // put the same path at two different ranks. + const snapshot = loadLayeredEnv(NAME, both, vi.fn()) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(both, '.env') }, + ]) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) }) describe('installFailLoud', () => { diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 11014f64b5..f35e32f9c5 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -76,6 +76,7 @@ export interface EnvironmentSnapshot { * @returns the key to store and look up by. */ function lookupKey(name: string): string { + /* v8 ignore next -- native Windows coverage exercises the folding arm; POSIX covers the exact one */ return process.platform === 'win32' ? name.toUpperCase() : name } From e1d226c4affa5137cb253a40b7a5f25aa7279e59 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 13:23:19 +0800 Subject: [PATCH 14/88] test: point the last two credential stores at the YAML document The e2e store and the web-search store still named a `.env` path; the e2e one also wrote dotenv syntax, which the YAML document rejects. That path now names the ordinary environment layer, so a test pointing the credential store at it asserts the distinction this PR removes. --- packages/llm/llm-deepseek/tests/adapter.e2e.ts | 6 ++++-- packages/web/web-search-deepseek/tests/deepseek.spec.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 5468cd8d9f..97ae629001 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -62,14 +62,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) try { - await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 }) + // JSON.stringify quotes the value: YAML is a JSON superset, so a real + // key survives whatever characters it happens to carry. + await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) // Scrub the ambient variable so only the credential seam can supply the // key: this request proves the per-request resolution path end to end. vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(LlmDeepSeek, {}) const result = await assemble(ctx, { diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 7990a96a9b..23c2d2c237 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -455,7 +455,7 @@ describe('web-search-deepseek plugin registration', () => { const ctx = new Context() try { await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false }) await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' }) await expect(ctx.web.search({ query: 'missing' })) From 70cf4a147145a8de4714140dd0e2d7b33c1d04f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 18:26:07 +0800 Subject: [PATCH 15/88] test(web): follow master's icon-only add-provider button The merge restored the icon variant of the Models add button; its accessible name no longer carries the `+` text prefix. --- apps/web/tests/models-settings.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 33e27628b0..694f268c59 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -58,7 +58,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. - const add = dialog.getByRole('button', { name: '+ 添加提供方' }) + const add = dialog.getByRole('button', { name: '添加提供方' }) await add.waitFor({ timeout: 10_000 }) // The button enables once the dormant catalog lands in the join. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) From d8d487236f656869428250cc0895afa859001e4b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 18:36:32 +0800 Subject: [PATCH 16/88] 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 c92a1da8135829d86e719e7defdb5f591601e81f Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 16:10:18 +0800 Subject: [PATCH 17/88] fix(ui): update hero headline copy --- packages/client/ui-conversation/src/client/locales.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9ba5ed3876..eec25939b3 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -44,7 +44,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', - 'hero.headline': '开始构建吧', + 'hero.headline': '探索未知之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', @@ -184,7 +184,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', - 'hero.headline': 'Let\'s start building', + 'hero.headline': 'Into the unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', From c6b581b4e8069de5cc5594427274f0468b58aaf4 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 16:26:53 +0800 Subject: [PATCH 18/88] test(ui): update hero headline expectations --- .../client/ui-conversation/tests/skeleton.spec.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2828bcc80..858cbe5b7b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -218,7 +218,7 @@ function mount( describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const view = render() - expect(view.getByText('Let\'s start building')).toBeTruthy() + expect(view.getByText('Into the unknown')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() }) }) @@ -282,7 +282,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('探索未知之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -306,7 +306,7 @@ describe('ConversationRoot resident composer', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') - expect(b.view.queryByText('开始构建吧')).toBeNull() + expect(b.view.queryByText('探索未知之境')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { @@ -331,7 +331,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('探索未知之境')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) @@ -349,7 +349,7 @@ describe('ConversationRoot resident composer', () => { expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) - expect(b.view.queryByText('开始构建吧')).toBeNull() + expect(b.view.queryByText('探索未知之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) From 6515988ec7264331dc89b5746dea7e7a7ae51059 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:40:48 +0800 Subject: [PATCH 19/88] Update startup-auto-selection.e2e.ts --- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index f3a953c1e6..141a5b427c 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -103,7 +103,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText("Let's start building").isVisible()).toBe(true) + expect(await page.getByText("Into the unknown").isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() From 9bb0aecb92a22a2472b76e5eb551dd4906164ee9 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:41:54 +0800 Subject: [PATCH 20/88] Update hmr-live.e2e.ts --- apps/web/tests/hmr-live.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index df81a10402..385a516e7d 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = "Let's start building" - const sourceNeedle = "'hero.headline': 'Let\\'s start building'" + const oldText = "Into the unknown" + const sourceNeedle = "'hero.headline': 'Into the unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) From 9a9bfbf306bbf5f0c57cabf18c33f9a2d1ce7bb0 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:51:23 +0800 Subject: [PATCH 21/88] Update lifecycle-chrome.e2e.ts --- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 8c81f55810..f37f02b6af 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -152,7 +152,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText("Into the unknown", { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { From c22337a71e237b7ec617fbb65c3fe6d49c76f968 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 17:59:42 +0800 Subject: [PATCH 22/88] Update hero.expected.md --- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 728dc768f8..ad060c5d59 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building Preview +- text: Into the unknown Preview - button "Choose workspace": - img - text: workspace From 342229dc14fa90a418fd47b16312db4a051afdb5 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 18:02:23 +0800 Subject: [PATCH 23/88] Update plan-active.expected.md --- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 6b4d7633e5..ce2ce36af0 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building Preview +- text: Into the unknown Preview - button "Choose workspace": - img - text: workspace From 2e943a16432e4572c87783efc43d0d7272daa64d Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Thu, 6 Aug 2026 18:31:44 +0800 Subject: [PATCH 24/88] Update details-session-lifecycle.e2e.ts --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index cb6c9ba914..5317c39009 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText("Into the unknown", { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) From 48fd9b70ea99af5974314590a3d283fee2a5182e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:43:40 +0800 Subject: [PATCH 25/88] 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 8f2168303b246d2f4a988b29dbaae3e5794b2c5a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:00:01 +0800 Subject: [PATCH 26/88] feat(web): add install metadata --- ...06-resolved-theme-color-metadata.i18n.yaml | 6 +++ ...026-08-06-resolved-theme-color-metadata.md | 31 +++++++++++++++ ...-08-06-resolved-theme-color-metadata.zh.md | 31 +++++++++++++++ .../2026-08-06-web-install-manifest.i18n.yaml | 6 +++ .../2026-08-06-web-install-manifest.md | 39 +++++++++++++++++++ .../2026-08-06-web-install-manifest.zh.md | 39 +++++++++++++++++++ apps/web/index.html | 1 + apps/web/public/manifest.webmanifest | 16 ++++++++ apps/web/tests/pwa-manifest.e2e.ts | 27 +++++++++++++ apps/web/tests/settings-chrome.e2e.ts | 36 ++++++++++++++--- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/theme-presenter.ts | 26 ++++++++++--- packages/client/ui-layout/tests/apply.spec.ts | 10 ++++- .../ui-layout/tests/theme-presenter.spec.ts | 36 +++++++++++++++-- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 +- packages/host/frontend-static/README.zh.md | 2 +- packages/host/frontend-static/src/index.ts | 1 + .../tests/frontend-static.spec.ts | 8 +++- 21 files changed, 305 insertions(+), 24 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md create mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/tests/pwa-manifest.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.i18n.yaml new file mode 100644 index 0000000000..7550af746a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.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-resolved-theme-color-metadata.md +2026-08-06-resolved-theme-color-metadata.md: 2f7a6f0bde5e75aeb6769939cae54d5319aa5bae +2026-08-06-resolved-theme-color-metadata.zh.md: a6d530f841d5c744fc88831f9cb685d1ab5027b6 diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md new file mode 100644 index 0000000000..2f7a6f0bde --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.md @@ -0,0 +1,31 @@ +# Agent Note: Resolved theme color metadata + +Status: implemented + +English | [中文](2026-08-06-resolved-theme-color-metadata.zh.md) + +## Problem + +The web client can resolve its theme independently of the operating-system preference, so a single manifest `theme_color` or media-qualified static metadata can disagree with an explicit Light or Dark selection. Browser chrome around an installed or ordinary page then need not match the app surface even though the layout presenter already owns the resolved document palette. + +## Decision + +The ui-layout `ThemePresenter` owns one `` alongside its root `color-scheme`, dark-palette attribute, and inline token writes. After applying a resolved snapshot's palette and token overrides, the presenter reads the body's computed `background-color` into the metadata element and inserts that single node into the document head. Subsequent snapshots update the same node, and disposal removes it. + +The rendered body background remains the color authority. The PWA manifest carries no static `theme_color` or `background_color`, and `ThemeDefinition` gains no second color field that could drift from the token palette. This also lets a registered theme's base-background token reach browser UI through the same application path as its page surface. + +## Verification + +The presenter unit contract covers light and dark computed colors, node reuse, and disposal. The ui-layout composition test covers initial insertion, event-driven reuse, and fiber cleanup. The Web browser settings scenario drives Light, Dark, System, operating-system changes, and reload through the shipped composition, asserting one metadata element whose content equals the computed body background with no console errors. The metadata change has no rendered accessibility-tree output, so the existing scenario golden remains unchanged. + +## Alternatives considered + +**Set `theme_color` in the manifest.** A manifest provides one app-wide value, so either built-in palette can disagree with it; the manifest deliberately omits the field. + +**Declare light and dark metadata with `prefers-color-scheme` media queries.** Media queries follow the operating system, not an explicit in-app selection, and therefore cannot represent the resolved preference. + +**Add a `themeColor` field to every `ThemeDefinition`.** A separate value gives custom themes an independent browser-chrome choice, but duplicates the base-background color and permits the page and surrounding UI to drift. A distinct field can be introduced if a supported theme needs that intentional difference. + +## Consequences + +Supporting browsers update surrounding UI after the client applies its initial resolved snapshot and after every theme change; browsers without `theme-color` support ignore the metadata. Because the value comes from computed presentation, the client must keep a concrete body background. The presenter creates and removes its own node, while unrelated head metadata remains untouched. diff --git a/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md new file mode 100644 index 0000000000..a6d530f841 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-resolved-theme-color-metadata.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 基于解析后主题的颜色元数据 + +Status: implemented + +[English](2026-08-06-resolved-theme-color-metadata.md) | 中文 + +## 问题 + +Web 客户端可以独立于操作系统偏好解析主题,因此 manifest(元数据清单)中单一的 `theme_color` 值或带媒体条件的静态元数据可能与显式选择的 Light 或 Dark 不一致。此时,无论是已安装页面还是普通页面,其周围的浏览器界面都未必与应用界面一致,尽管布局呈现器已经拥有解析后的 document 调色板。 + +## 决策 + +ui-layout 的 `ThemePresenter` 拥有一个 ``,与根元素上的 `color-scheme`、深色调色板属性和内联 token 写入并列。在应用解析后快照的调色板与 token 覆盖值之后,呈现器读取 body 计算样式中的 `background-color`,写入该元数据元素,再将该节点插入 document head。后续快照会更新同一节点,资源释放时则移除它。 + +渲染后的 body 背景仍是颜色真源。PWA manifest 不包含静态 `theme_color` 或 `background_color`,`ThemeDefinition` 也不新增可能与 token 调色板偏离的第二个颜色字段。这样一来,注册主题的基础背景 token 也能通过页面界面使用的同一条应用路径作用于浏览器界面。 + +## 验证 + +呈现器的单元测试契约覆盖浅色和深色模式下的计算颜色、节点复用及资源释放。ui-layout 组合测试覆盖初始插入、事件驱动的复用和 fiber 清理。Web 浏览器设置场景通过实际交付的组合依次驱动 Light、Dark、System、操作系统偏好变化和重新加载,并断言页面始终只有一个元数据元素,其内容等于计算后的 body 背景且控制台无错误。这项元数据变更不会出现在渲染后的无障碍树输出中,因此场景现有的预期输出保持不变。 + +## 曾考虑的替代方案 + +**在 manifest 中设置 `theme_color`。** manifest 只能提供一个适用于整个应用的值,因此任一内置调色板都可能与之不一致;manifest 有意省略该字段。 + +**用 `prefers-color-scheme` 媒体查询声明浅色和深色元数据。** 媒体查询跟随操作系统,而非应用内显式选择,因此无法表示解析后的偏好。 + +**为每个 `ThemeDefinition` 添加 `themeColor` 字段。** 单独的值可让自定义主题独立选择浏览器界面配色,但会复制基础背景色,并允许页面与周围的浏览器界面发生偏离。如果受支持的主题需要这种有意差异,可以再引入独立字段。 + +## 后果 + +支持该元数据的浏览器会在客户端应用初始解析后快照及之后每次主题变化时更新周围界面;不支持 `theme-color` 的浏览器会忽略这项元数据。由于该值来自计算后的呈现结果,客户端必须确保 body 始终有明确的背景色。呈现器会创建并移除自己的节点,head 中无关的元数据则保持不变。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.i18n.yaml new file mode 100644 index 0000000000..d13ede02d2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.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-web-install-manifest.md +2026-08-06-web-install-manifest.md: d400c6e586f4b735fa8e3dcc4899c97e45ac89c1 +2026-08-06-web-install-manifest.zh.md: a7fee0248261e8d0597bb773d4f390973147337b diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md new file mode 100644 index 0000000000..d400c6e586 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.md @@ -0,0 +1,39 @@ +# Agent Note: Web install manifest metadata + +Status: implemented + +English | [中文](2026-08-06-web-install-manifest.zh.md) + +## Problem + +The Web build has a document title and favicon but no manifest from which a browser can discover a stable installed identity, launch boundary, or installed presentation. Adding that metadata can also imply capabilities the app does not provide: a service worker suggests an offline contract, while a single language or palette value misrepresents a bilingual UI with resolved light and dark themes. + +## Decision + +The Web entry links `/manifest.webmanifest`, which Vite copies from `apps/web/public/` into the production build. The manifest names the product `DeepSeek Harness`, gives installed chrome the compact name `DSH`, and fixes `id`, `start_url`, and `scope` at `/`. It requests `display: "fullscreen"` so supporting browsers can give the installed editor-like surface the available display area while leaving ordinary tabs unchanged; browsers may apply user overrides or fall back to another display mode. Its icon entry reuses `/favicon.svg` as an SVG of size `any` and purpose `any`. + +This follows code-server's fullscreen choice without copying its `window-controls-overlay` display override. DSH has no custom title bar or layout around native window controls, so such an override would supersede fullscreen without owning the required safe layout. + +The manifest deliberately has no `lang`, `theme_color`, or `background_color`. The product surface is bilingual rather than owned by one manifest language, and either static color can disagree with one of the resolved app palettes. Theme metadata therefore remains outside the install manifest. + +This feature adds no service worker, cache policy, or offline fallback. The manifest supplies install metadata only; browser eligibility and install affordances remain browser policy. The shipped [`dsh-frontend-static`](../../../../packages/host/frontend-static/README.md) fallback recognizes `.webmanifest` as `application/manifest+json` so the same asset is valid through the shipped HTTP composition rather than only in Vite's output directory. + +## Verification + +The built-Web test parses the emitted manifest and pins the complete metadata object, including the human-visible name, compact name, icon, root identity, launch boundary, and display mode, while also verifying that the production `index.html` retains the link. The `dsh-frontend-static` real Loader composition test serves a `.webmanifest` fixture and pins its `application/manifest+json` media type. + +## Alternatives considered + +**Add a service worker and call the app offline-capable.** Rejected because caching the shell without defining session transport, invalidation, failure behavior, and upgrade semantics would create a misleading partial offline contract. + +**Declare one `lang`.** Rejected because no single language describes the bilingual product surface; omission avoids claiming that one locale owns the installed experience. + +**Choose one static background and theme color.** Rejected because the app resolves light and dark palettes at runtime, so either fixed value is knowingly wrong for one supported state. + +**Ship raster and maskable icon variants immediately.** Rejected until a supported installation target demonstrates a requirement the existing scalable favicon cannot meet. New variants remain an additive manifest change rather than a prerequisite for exposing the current identity. + +**Assert only root and display fields in the built artifact.** Rejected because dropping or changing the product name, compact name, or icon is also a shipped install regression. The test intentionally requires an explicit edit whenever any manifest metadata changes. + +## Consequences + +Supporting browsers can discover a stable root-scoped installed identity and fullscreen preference without the application promising offline behavior. Deploying this build below a path prefix requires revisiting the absolute link, identity, launch, scope, and icon URLs together. Browser-specific icon requirements may add variants later, and every intentional metadata change updates the exact built-artifact contract. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md new file mode 100644 index 0000000000..a7fee02482 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-install-manifest.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Web 安装 manifest 元数据 + +Status: implemented + +[English](2026-08-06-web-install-manifest.md) | 中文 + +## 问题 + +Web 构建产物已有文档标题和 favicon,却没有可供浏览器发现稳定安装身份、启动边界或安装后呈现方式的 manifest(元数据清单)。添加这类元数据也可能暗示应用并不具备的能力:service worker 会让人以为应用提供离线契约,而单一语言或调色板取值会错误描述这个能够解析浅色与深色主题的双语 UI。 + +## 决策 + +Web 入口链接 `/manifest.webmanifest`,Vite 会将其从 `apps/web/public/` 复制到生产构建产物。manifest 将产品命名为 `DeepSeek Harness`,为安装后的浏览器界面提供简称 `DSH`,并把 `id`、`start_url` 和 `scope` 固定为 `/`。它请求 `display: "fullscreen"`,使支持这一模式的浏览器能够把可用显示区域交给安装后的编辑器式界面,同时不改变普通标签页;浏览器可以应用用户覆盖设置,或回退到其他显示模式。其图标条目复用 `/favicon.svg`,将它作为尺寸为 `any`、用途为 `any` 的 SVG。 + +这一选择沿用了 code-server 的全屏方案,但没有照搬其 `window-controls-overlay` 显示覆盖项。DSH 没有自定义标题栏,也没有围绕原生窗口控件安排布局,因此使用这类覆盖项会在未落实所需安全布局的情况下取代全屏模式。 + +manifest 有意不包含 `lang`、`theme_color` 或 `background_color`。产品界面支持双语,并不由 manifest 中的单一语言定义;任一静态颜色值都可能与应用解析后的一套调色板不一致。因此,主题元数据仍放在安装 manifest 之外。 + +该功能不添加 service worker、缓存策略或离线回退。manifest 只提供安装元数据;是否具备安装资格、是否提供安装入口仍由浏览器策略决定。实际交付的 [`dsh-frontend-static`](../../../../packages/host/frontend-static/README.md) 回退将 `.webmanifest` 识别为 `application/manifest+json`,因此同一资产经实际交付的 HTTP 组合提供时同样有效,而不只在 Vite 输出目录中有效。 + +## 验证 + +Web 构建产物测试解析输出的 manifest,并固定完整的元数据对象,包括面向用户显示的名称、简称、图标、根路径身份、启动边界和显示模式,同时验证生产构建的 `index.html` 仍保留该链接。`dsh-frontend-static` 的真实 Loader 组合测试提供一个 `.webmanifest` fixture(测试前置数据),并固定其 `application/manifest+json` 媒体类型。 + +## 曾考虑的替代方案 + +**添加 service worker,并宣称应用支持离线。** 不予采纳,因为只缓存应用外壳,却不定义会话传输、失效策略、失败行为和升级语义,会形成具有误导性的不完整离线契约。 + +**声明单一的 `lang`。** 不予采纳,因为没有任何一种语言足以描述双语产品界面;省略该字段可避免声称安装后的体验由某一种区域设置独占。 + +**选择一组静态背景色和主题色。** 不予采纳,因为应用会在运行时解析浅色和深色调色板,因此选择任一固定值,都是明知它与其中一种受支持状态不符。 + +**立即交付光栅和可遮罩图标变体。** 在某个受支持的安装目标证明现有可缩放 favicon 无法满足其要求之前,不予采纳。新变体只是对 manifest 的增量扩展,并非公开当前身份的前提。 + +**只断言构建产物中的根路径字段和显示字段。** 不予采纳,因为产品名称、简称或图标被删除或更改,同样属于已交付安装体验的回归。任何 manifest 元数据发生变化时,测试都有意要求显式改动。 + +## 后果 + +支持这一机制的浏览器可以发现以根路径为作用域的稳定安装身份和全屏偏好,而应用无需承诺离线行为。在路径前缀下部署该构建产物时,必须同时重新审视绝对路径的 manifest 链接,以及身份、启动、作用域和图标 URL。日后可能因浏览器特有的图标要求而新增变体;每一项有意的元数据变更都会同步更新精确的构建产物契约。 diff --git a/apps/web/index.html b/apps/web/index.html index c9fc7d124c..a14de72d40 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,7 @@ + DeepSeek Harness diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000000..20a428fee6 --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "id": "/", + "name": "DeepSeek Harness", + "short_name": "DSH", + "start_url": "/", + "scope": "/", + "display": "fullscreen", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts new file mode 100644 index 0000000000..696e1c7797 --- /dev/null +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { expect, it } from 'vitest' + +const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) + +it('ships install metadata with the built web application', async () => { + const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') + expect(index).toContain('') + + const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8')) + expect(manifest).toEqual({ + id: '/', + name: 'DeepSeek Harness', + short_name: 'DSH', + start_url: '/', + scope: '/', + display: 'fullscreen', + icons: [{ + src: '/favicon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }], + }) +}) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 43500585d8..e6b10664af 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenarios: the settings surface — the modal shell (trigger, nav, // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme -// -> theme/change -> ui-layout's presenter -> body attribute -> alias token) +// -> theme/change -> ui-layout's presenter -> body attribute -> alias token + +// browser theme-color metadata) // the Language row (settings-scoped localization + persisted dsh.locale), // the busy-state Enter preference, plus Permission as the persisted default // for subsequently created sessions. @@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => { it('flips the theme through the Appearance cubes and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) - const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => - await page.evaluate(() => ({ + interface ThemeState { + attr: boolean + background: string + stored: string | null + themeColor: string | null + themeColorCount: number + token: string + } + const readState = async (): Promise => await page.evaluate(() => { + const metas = document.head.querySelectorAll('meta[name="theme-color"]') + const computed = getComputedStyle(document.body) + return { attr: document.body.hasAttribute('data-ds-dark-theme'), - token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), + background: computed.backgroundColor, stored: localStorage.getItem('dsh.theme'), - })) + themeColor: metas[0]?.content ?? null, + themeColorCount: metas.length, + token: computed.getPropertyValue('--dsw-alias-bg-base').trim(), + } + }) + const expectThemeColorSynchronized = (state: ThemeState): void => { + expect(state.themeColorCount).toBe(1) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + expect(state.themeColor).toBe(state.background) + } // Pin the OS scheme to light so the default `system` preference resolves // light and the dark flip below is unambiguously the gesture's doing. await page.emulateMedia({ colorScheme: 'light' }) const light = await readState() expect(light.attr).toBe(false) + expectThemeColorSynchronized(light) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(dark.attr).toBe(true) expect(dark.stored).toBe('dark') expect(dark.token).not.toBe(light.token) + expectThemeColorSynchronized(dark) await page.keyboard.press('Escape') // Reload: the preference survives boot (restore + presenter initial apply). @@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => { const reloaded = await readState() expect(reloaded.attr).toBe(true) expect(reloaded.stored).toBe('dark') + expectThemeColorSynchronized(reloaded) // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() @@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => { await systemCube.click() await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.emulateMedia({ colorScheme: 'dark' }) await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + expectThemeColorSynchronized(await readState()) // Restore for the specs that follow: light preference beats the emulated // dark OS scheme, leaving the shared page in the light default. await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click() await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false) + expectThemeColorSynchronized(await readState()) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 8b5aff5db5..d04c06b3da 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/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-layout/README.md -README.md: 5cb8f01efb2e18109e917225dbce088ea77394af -README.zh.md: 6559fe595a6219b139fe46cf046906fa63636f64 +README.md: fa60520a20ac8a7f25d494879c68efb06a28998f +README.zh.md: 6ca04c56c29a55f84fc7a6399a7feeb81d249899 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 5cb8f01efb..fa60520a20 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, the theme's alias tokens as inline variables on body, and one owned `` whose content follows the computed body background). Measuring after palette and token application keeps the rendered background as the single color authority; disposing the presenter removes its metadata node with its other global writes. AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 6559fe595a..6ca04c56c2 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制栏,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量,同时拥有一个 ``,其内容随计算后的 body 背景色更新)。在应用调色板和 token 后进行测量,可确保渲染后的背景保持为唯一颜色真源;呈现器在资源释放时会移除其自有的元数据节点,并一并清除其写入的其他全局状态。 AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。 diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts index 07dc663c54..87e3592798 100644 --- a/packages/client/ui-layout/src/client/theme-presenter.ts +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -1,10 +1,11 @@ /** * Global theme DOM applier: projects the resolved ThemeSnapshot onto the * document — `html { color-scheme }` for native UA chrome (scrollbars, form - * controls), `body[data-ds-dark-theme]` for the token palette, and the active - * theme's alias-token overrides as inline CSS variables on body. Pure DOM - * writes, no React involvement; the presenter only ever retracts what it wrote - * itself, so foreign attributes and inline styles survive apply/dispose. + * controls), `body[data-ds-dark-theme]` for the token palette, the active + * theme's alias-token overrides as inline CSS variables on body, and one + * presenter-owned `meta[name="theme-color"]` for surrounding browser UI. Pure + * DOM writes, no React involvement; the presenter only ever retracts what it + * wrote itself, so foreign attributes, metadata, and inline styles survive. */ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -15,12 +16,22 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme' export class ThemePresenter { /** Token names this presenter wrote in the last apply (its retraction set). */ private appliedTokens: string[] = [] + /** The single metadata node this presenter inserts and removes. */ + private readonly themeColorMeta: HTMLMetaElement + + /** Create the presenter-owned metadata node before the first snapshot arrives. */ + constructor() { + this.themeColorMeta = document.createElement('meta') + this.themeColorMeta.name = 'theme-color' + } /** * Project a snapshot onto the document: set root `color-scheme` and the body * palette attribute from `active.colorScheme` (never the id — `system` is * resolved upstream), then replace the previously applied token variables - * with `active.tokens`. + * with `active.tokens`. Browser theme-color metadata follows the computed + * body background after those writes, so the rendered palette remains the + * color authority. * @param snapshot - resolved theme snapshot from ctx.theme. */ apply(snapshot: ThemeSnapshot): void { @@ -35,14 +46,17 @@ export class ThemePresenter { body.style.setProperty(name, value) this.appliedTokens.push(name) } + this.themeColorMeta.content = getComputedStyle(body).backgroundColor + if (!this.themeColorMeta.isConnected) document.head.append(this.themeColorMeta) } - /** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */ + /** Retract root color-scheme, the palette attribute, token variables, and the owned metadata node. */ dispose(): void { document.documentElement.style.removeProperty('color-scheme') const body = document.body body.removeAttribute(DARK_ATTRIBUTE) for (const name of this.appliedTokens) body.style.removeProperty(name) this.appliedTokens = [] + this.themeColorMeta.remove() } } diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 903591163c..af85c1c5ae 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -7,7 +7,7 @@ // coverage gate still requires exercised. import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -15,6 +15,10 @@ import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' +beforeEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) +}) + async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) @@ -65,13 +69,17 @@ describe('ui-layout client apply', () => { // Initial getter application: jsdom has no matchMedia, system resolves light. expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + const themeColorMeta = document.head.querySelector('meta[name="theme-color"]') + expect(themeColorMeta).not.toBeNull() const theme = ctx.get('theme') as ThemeService theme.setTheme('dark') expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta) await fiber.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + expect(themeColorMeta?.isConnected).toBe(false) // Listener is off: further theme changes no longer reach the document. theme.setTheme('light') theme.setTheme('dark') diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts index a14d781e5f..36a4975fc9 100644 --- a/packages/client/ui-layout/tests/theme-presenter.spec.ts +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -1,40 +1,68 @@ // @vitest-environment jsdom // ThemePresenter behavior account: root color-scheme and the palette attribute // follow active.colorScheme only, token variables replace the previous apply's -// set, and dispose retracts everything the presenter wrote. +// set, theme-color metadata follows the rendered body background, and dispose +// retracts everything the presenter wrote. -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts' +const LIGHT_THEME_COLOR = 'rgb(255, 255, 255)' +const DARK_THEME_COLOR = 'rgb(21, 21, 23)' + function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}): ThemeSnapshot { // The presenter must key off colorScheme, not the id — keep them distinct. const active = { id: `${colorScheme}-test`, colorScheme, tokens } return { preference: colorScheme, active, themes: [active], revision: 1 } } +function clearThemePresentation(): void { + document.head.querySelectorAll('meta[name="theme-color"], style[data-theme-presenter-test]').forEach((node) => { node.remove() }) +} + +function themeColorMeta(): HTMLMetaElement | null { + return document.head.querySelector('meta[name="theme-color"]') +} + beforeEach(() => { + clearThemePresentation() document.documentElement.style.removeProperty('color-scheme') document.body.removeAttribute(DARK_ATTRIBUTE) document.body.removeAttribute('style') + const style = document.createElement('style') + style.dataset.themePresenterTest = '' + style.textContent = ` + body { background-color: ${LIGHT_THEME_COLOR}; } + body[${DARK_ATTRIBUTE}] { background-color: ${DARK_THEME_COLOR}; } + ` + document.head.append(style) }) +afterEach(clearThemePresentation) + describe('ThemePresenter', () => { it('light scheme sets root color-scheme and leaves the dark attribute absent', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()?.content).toBe(LIGHT_THEME_COLOR) }) - it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => { + it('dark scheme sets root color-scheme, the attribute, and metadata; switching to light updates one node', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('dark')) + const meta = themeColorMeta() expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) + expect(meta?.content).toBe(DARK_THEME_COLOR) presenter.apply(snapshot('light')) expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(themeColorMeta()).toBe(meta) + expect(meta?.content).toBe(LIGHT_THEME_COLOR) + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1) }) it('applies tokens as inline variables and clears the previous set on theme change', () => { @@ -52,10 +80,12 @@ describe('ThemePresenter', () => { document.body.style.setProperty('--foreign', 'kept') const presenter = new ThemePresenter() presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) + const meta = themeColorMeta() presenter.dispose() expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') + expect(meta?.isConnected).toBe(false) }) }) diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 07d337775e..9d757aaf6c 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/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/frontend-static/README.md -README.md: c3a831abb1060b59e1802d38d5407a29d24e3bb3 -README.zh.md: d4dc71763280a3c88c73de50f63f2615570c7182 +README.md: 82ba5a2cd0937e2c24505648aa1e3daec6bf2ece +README.zh.md: 1130aa7cc241ee5245fceaba0ef66fcf95871e67 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index c3a831abb1..82ba5a2cd0 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. +- **The starter MIME table is minimal** — it covers the Vite-emitted asset set plus the shipped PWA manifest; other extensions fall back to `application/octet-stream` until an asset class actually ships. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index d4dc717632..1130aa7cc2 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -16,4 +16,4 @@ Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`) ## 已知限制与延期工作 -- **初始 MIME 表很精简**:vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 +- **初始 MIME 表很精简**:它覆盖 Vite 输出的资产集合及实际交付的 PWA manifest;其他扩展名在相应资产类别实际发布前都会回退到 `application/octet-stream`。 diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 4d5032c2d2..8bd5b829c1 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -41,6 +41,7 @@ const MIME: Record = { '.svg': 'image/svg+xml', '.json': 'application/json', '.map': 'application/json', + '.webmanifest': 'application/manifest+json', } /** diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index e35e54bb05..4f5fa0d2c7 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -36,6 +36,7 @@ async function loadComposition(): Promise { await writeFile(distIndex, 'shell') await writeFile(join(dist, 'app.js'), 'export {}') await writeFile(join(dist, 'blob.bin'), 'BLOB') + await writeFile(join(dist, 'manifest.webmanifest'), '{}') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-host-webserver'", @@ -92,8 +93,13 @@ describe('real Loader composition', () => { const server = loaded.httpServer const port = server.port - // Real asset with its MIME type; a live rebuild is served on the next read. + // Real assets with their MIME types; a live rebuild is served on the next read. expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' }) + expect(await request(port, '/manifest.webmanifest')).toMatchObject({ + status: 200, + type: 'application/manifest+json', + body: '{}', + }) await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) From 716361844c8616a24f38373d9d8d4e80e44752c1 Mon Sep 17 00:00:00 2001 From: Jiaying Ding Date: Fri, 7 Aug 2026 11:54:45 +0800 Subject: [PATCH 27/88] style(web): use single quotes in hero expectations --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/hmr-live.e2e.ts | 2 +- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 5317c39009..3bd781a5ee 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText("Into the unknown", { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText('Into the unknown', { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 385a516e7d..1e8e81909f 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,7 +75,7 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = "Into the unknown" + const oldText = 'Into the unknown' const sourceNeedle = "'hero.headline': 'Into the unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index e9afa95872..90587e9e4c 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText("Into the unknown", { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Into the unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 7ef43d2861..c93ed04a40 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText("Into the unknown").isVisible()).toBe(true) + expect(await page.getByText('Into the unknown').isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() 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 28/88] 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 effd8e1ebd5b2146759d80c81bfa8f27b1cfcb3a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:13:52 +0800 Subject: [PATCH 29/88] docs: add TypeRT remote gateway RFC --- ...08-02-typert-remote-method-calls.i18n.yaml | 6 + .../2026-08-02-typert-remote-method-calls.md | 489 ++++++++++++++++++ ...026-08-02-typert-remote-method-calls.zh.md | 489 ++++++++++++++++++ 3 files changed, 984 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md create mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml new file mode 100644 index 0000000000..cc2f0736d4 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.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/proposed/architecture/2026-08-02-typert-remote-method-calls.md +2026-08-02-typert-remote-method-calls.md: c3a7a77c583720c3f967de185a089d374f017d81 +2026-08-02-typert-remote-method-calls.zh.md: 9b2fbbd69f1c054cbf6c86f177b743c583be3e8a diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md new file mode 100644 index 0000000000..c3a7a77c58 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md @@ -0,0 +1,489 @@ +# Agent Note: TypeRT Gateway Targeted Method Calls + +Status: proposed + +English | [中文](2026-08-02-typert-remote-method-calls.zh.md) + +## Problem + +The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. + +This proposal addresses only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, do not use this design and will be designed separately. + +The contract for a direct method call belongs to the business Service that implements it. Business developers should declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. + +The Host and Browser Client use separate TypeScript Programs because each side augments the Cordis `Context` type differently. A Remote projection must not import the complete Host declarations into a consumer or depend on Browser-specific types. If the TUI later reuses this programming interface, it must likewise see only methods marked Remote. TUI integration is outside the current scope, but the implementation boundary must preserve this isomorphic reuse. + +## Proposal + +A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. + +The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. + +`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over the single Connection/RPC mechanism through an isolated `/api2` channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. + +## Components and Cordis services + +| Component | Cordis service | Responsibility in this proposal | +|---|---|---| +| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | +| TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | +| Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, RPC envelope, rpcId, serialization, trust, and error transport, while carrying the isolated `/api` and `/api2` channels | +| Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | +| Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | +| Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | + +The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client API does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Client API namespaces. + +## Business declarations + +Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: + +```text +export class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return this.create(agent, request) + } +} +``` + +`goals` is an explicit Cordis service key and is the default wire namespace. Override it through an option to `bindTypeRTGateway()` only when the protocol namespace genuinely needs to differ from the service key. + +Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: + +```text +export class ScopedGoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @RemoteContext('agent', 'create') + remoteExportCreate(request: CreateGoalRequest): Promise { + // Runs against the goals service resolved from the Agent Context. + } +} +``` + +An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. + +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. + +## Decorators and the explicit Gateway facet + +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. + +In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. + +In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. Generation neither rewrites business source nor secretly supplies generated arguments to `bindTypeRTGateway()`. + +## Lookup and Remote Context registration + +The Gateway has no built-in branches for Agent, Session, or other business objects. Each object-owning package provides both a static declaration and a runtime provider: + +```text +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } +} + +ctx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + resolve: sessionId => resolveAgent(sessionId), +}) +``` + +The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on the wire. The runtime provider resolves an `agentId` in a request to the currently live `Agent` object. If either side is missing, the LIB build or the earliest resolvable runtime registration fails immediately. + +Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this proposal does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. + +Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. + +The Client also registers an `agent` Context binder. The binder only retrieves a `SessionId` from the Context in which a call occurs; it neither enumerates Scopes nor copies methods into each one. A Cordis Service tracker automatically rebinds a scoped namespace to the current Agent Context. + +## InvocationDescriptor + +TypeRT, the permissive SRC parser, Host Gateway, and Client API exchange one canonical description: + +```text +InvocationDescriptor { + id: '@deepseek-ai/dsh-goal#goals/create' + service: 'goals' + namespace: 'goals' + method: 'create' + implementation: 'remoteExportCreate' + invocation: direct | { context: 'agent', wire: 'agentId' } + scope?: { context: 'agent', wire: 'agentId' } + parameters: [ + { name, wire, source: json | lookup, lookup?, codec } + ] + result: codec + sourceLocation +} +``` + +`method` is the external short name used by the endpoint and Client API; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. + +The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. + +Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. + +A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. + +Descriptors exist only in the local registry on each side. The wire carries only the `/api2` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. + +## TypeRT runtime registry + +```text +ctx.typert.local 当前进程自己的 Host 或 Client reflection +ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution +ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.contexts Host Context resolver 与 Client Context binder +``` + +Every registration returns a disposer owned by the caller's Cordis fiber. The Gateway and API Service read the current snapshot before subscribing to changes, so business Services, generated contributions, providers, and consumers can load in any order. When any dependency is disposed, its related endpoints or methods become unavailable immediately. + +The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. + +## Canonical types, symbols, and Zod + +Remote Client DTS does not copy business DTOs or redeclare structurally identical shadow types. It imports original symbols only from public, type-only subpaths that do not carry Host Cordis merges: + +```text +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types' +``` + +Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. + +Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the method-name token of the Host `remoteExport*` method and emits a source-map segment on the corresponding property of the namespace interface. After the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. + +TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. + +Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names. + +A lookup parameter does not expose the `Agent` class to consumers. The Remote projection refers to the canonical ID type in the lookup declaration, such as `SessionId`, while the Host continues to resolve objects through the canonical `Agent` class symbol. + +## Three artifact kinds and two TypeScript Programs + +The Host and Client still use only two independent TypeScript Programs, but TypeRT generates three semantically distinct kinds of artifacts: + +```text +Host Program +├─ typert.host.js / typert.host.d.ts +│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息 +└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map + Host Remote 对任意消费环境的 wire 投影 + +Client Program +└─ typert.client.js / typert.client.d.ts + Client 自身的 Service、Event、Object 和 schema 信息 +``` + +`remote-client` is the Host Program's second emitter, not a third Program or the Client's local face. It contains no Host Cordis merge, Service class, Context class, or implementation code, and it does not enter the Host-local reflection registry. + +The Host lib build performs strict Host analysis and emits both the Host-local and Remote consumer artifacts. The Client lib then consumes the Remote DTS. The complete order is: + +```text +Host lib build +→ 生成 typert.host.{js,d.ts} +→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map} +→ 完成 Client lib 和 typert.client 产物 +→ Vite 构建 Web +``` + +The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. + +## The `/remote` package entry + +Every business package that provides Remote methods exports a generated `/remote` subpath: + +```text +"./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" +} +``` + +Consumer code selects a capability through the business package itself: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +``` + +This import brings the `.d.ts` map augmentation into the current TypeScript project while supplying the JS descriptor for the same contract as a value to the runtime. A business package that is not imported does not extend the current project's Remote API types. + +The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. + +Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the API Service. + +Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules. + +## Strict consumer API types + +Remote DTS extends the flat endpoint map, direct namespace interface, namespace map, and scoped map without augmenting the global Cordis `Context`: + +```text +interface TypeRTRemoteNamespace$676f616c73 { + create: ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteContextMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + ) => Promise +} +``` + +`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root API type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. + +TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: + +```text +api.goals.create(agentId, request) +agent.goals.create(request) +``` + +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. In this phase, only the Client Agent Context gains `goals`; the Root Context does not. A future TUI must preserve the same Scope restriction. + +`RemoteApi` remains platform-independent, and the Browser Client uses it as its `ClientApi`. If a future TUI reuses this type, it must likewise access it through a dedicated API object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. + +## Client TypeRT and the API Gateway Client face + +TypeRT in a consumer environment maintains both local information and Remote information imported from other environments, but stores them in separate registries: + +```text +TypeRT.local 当前环境自己的反射模型 +TypeRT.remotes 已导入的 Remote contribution +``` + +`@deepseek-ai/dsh-client-remotes/client` centrally loads the required Remote contributions: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import sessionsRemote from '@deepseek-ai/dsh-session/remote' + +ctx.api.mount(goalsRemote) +ctx.api.mount(sessionsRemote) +``` + +Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client`, not directly on the Host API Gateway or the runtime entry of each business `/remote`. Client Remotes itself depends on the Gateway Client face and re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. + +`ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. + +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api2', endpoint, { args })`. + +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api2` call. + +```text +root ctx.api.goals.create(agentId, request) + → direct descriptor + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + +agent.goals.create(request) + → tracker 将 namespace Service rebind 到 agent Context + → agent binder 从 caller Context 取得 agentId + → 用 agentId 补入同一 direct descriptor 的 lookup 参数 + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) +``` + +The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. + +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service can create real functions from that data, so this proposal does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. + +## Cross-environment isomorphism constraints + +Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. + +Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api2` RPC calls. + +A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. + +TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. + +The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers must rebuild the lib and then start or restart the Web. The first phase does not implement incremental watching of the Remote contract. + +## SRC and LIB operating modes + +SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. + +For example, `@Remote('create') remoteExportCreate(agent, request)` resolves to the external method `create`, implementation member `remoteExportCreate`, and two top-level parameters. Lookup registration rewrites `agent` to the wire field `agentId`, while `request` is passed as a same-named JSON parameter. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. + +A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. + +LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, and result codecs, then generates strict descriptors. + +At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation. + +CI and releases use LIB. Moving all repository coverage to LIB is separate follow-up work and does not block this direct-method-call implementation. + +## Host Gateway registration + +The Host Gateway observes both TypeRT Remote definitions and the Cordis Service lifecycle. When a Service carrying the `typertGateway` facet and a definition with the same service key are both available, the Gateway registers the definition's endpoints. Their arrival order does not matter. + +At startup, the Gateway reads the current snapshots of TypeRT definitions and the Cordis reflection store before subscribing to registry changes and `internal/service`. It reconciles definitions, live Services, and bindings by service key, and unregisters endpoints when a Service is replaced or disposed. If a definition, lookup provider, or Context provider is removed, dependent endpoints immediately become unavailable; the Gateway neither retains invalid objects nor degrades to invoking methods with raw IDs. + +An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. + +A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. + +```text +ctx.typertGateway.invoke({ namespace, method, args }) +→ 查找本地 InvocationDescriptor 与 live receiver +→ 按参数 descriptor 读取具名 wire 字段 +→ codec 解码普通值或 lookup ID +→ lookup provider 把 ID 解析为活对象 +→ direct 使用原 Service;context 先解析 scoped Context 和 Service +→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) +→ result codec 编码业务结果 +``` + +`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport. + +## The `/api2` call chain + +`/api2` is an isolated protocol channel on the single Connection/RPC mechanism, not a transport created by the Gateway. The Gateway registers one local handler with Connection. This phase adds the following general channel capability to the existing HTTP Connection: + +```text +ctx.connection.rpc.handle('/api2', (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) +}) +``` + +The Connection Host half obtains a handle from the single HTTP Server and reuses the same RPC bridge, request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. Its current physical mapping is: + +```text +POST /api2// +``` + +The Remote payload is a named JSON object, not a positional array, and does not carry an `InvocationDescriptor`. A normal Goal call has this payload slot: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +The complete path is: + +```text +ctx.api.goals.create(sessionId, request) +→ Client InvocationDescriptor 编码 { args: { agentId, request } } +→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ Connection 创建 rpcId 和既有 client-request envelope +→ 当前 carrier 发送 POST /api2/goals/create +→ Connection Host half 执行 trust、反序列化和 RPC 分发 +→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ result codec 编码 +→ Connection 写入既有 RPC result 并回送相同 rpcId +→ Client result codec 验证并返回 CreateGoalResult +``` + +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. + +The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. This work only extends Connection with general channel registration and invocation capabilities. It does not change existing `/api`, trusted connection, trusted-host, or privileged-method semantics. Connection's WebSocket migration remains separate follow-up work. + +## Connection and protocol boundaries + +The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, lookup, Context, and business invocation. Connection only sends `/api2`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. + +`/api` and `/api2` share one Connection, Server, RPC envelope, and connection lifecycle while remaining separate protocols. When Connection migrates from HTTP to WebSocket, `/api2` naturally changes from a physical path to a logical channel. The Remote payload, business decorators, generated DTS, Remote API types, and Agent Scope programming interface remain unchanged. + +## Package boundaries + +- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. +- TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. +- TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. +- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api2` handler with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. +- Connection: owns the single HTTP Server/future WebSocket carrier, RPC envelope, rpcId, serialization, trust, and error transport while carrying the isolated `/api` and `/api2` channels. +- Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. +- Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. + +## Initial implementation scope + +The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. + +This phase implements Connection's general second-channel API and its current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. + +## Alternatives considered + +**Continue using the central API Proxy package.** This would require business methods, Host routes, and Client interfaces to be declared repeatedly in several locations. It would also keep direct calls, stateful interactions, and event streams tied to the same lifecycle, so this alternative is rejected. + +**Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so TypeRT generates strict information at compile time. + +**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass. + +**Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program. + +**Use a TypeScript language-service/compiler plugin to make the Client understand decorators directly.** This would require editors, Vite, tsc, tsx, and published consumers to install an additional plugin, making integration too invasive. The design instead generates ordinary `.d.ts` files and standard declaration maps. + +**Import complete Host DTS into the Client or TUI.** This would pull in Host Services and Cordis interface merges while exposing unmarked methods to consumers. Remote DTS refers only to public, type-only symbols and augments dedicated Remote maps. + +**Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well. + +**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. + +**Create a separate transport, HTTP route, and response envelope for Remote.** This would duplicate the existing Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle, requiring two RPC stacks to migrate separately. `/api2` instead reuses the single Connection/RPC mechanism as an isolated protocol channel. + +## Acceptance criteria + +- Goal Service retains its existing business method and adds a remote entry point at the end of the class through an explicit `typertGateway` and `@Remote('create') remoteExportCreate(...)`, without maintaining a second route, codec, or Client method list. +- One clean `build:lib` generates the Host Remote contract before compiling Host and Client consumers and produces JS, DTS, and a DTS map under the business package's `lib`, importable through `/remote`. +- After importing `@deepseek-ai/dsh-goal/remote`, a consumer project gets a strict `api.goals.create(...)` type; without the import, that namespace does not enter its types. Go to Definition on `create` follows the declaration map to the Host Service's `remoteExportCreate` implementation. +- After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. +- Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. +- `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. +- `/api2/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. +- `/api2` and `/api` share the single Connection/RPC carrier while remaining protocol-isolated. Remote neither registers an HTTP Server handle directly nor defines a second response envelope. +- Connection provides general channel registration and invocation capabilities and maps `/api2` to the current HTTP carrier. Existing `/api` behavior and trust semantics remain unchanged. +- This implementation does not change existing `/api`, Connection/trusted connection, Permission/Approval, or Session event stream behavior. + +## Risks + +Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts. + +Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract. + +The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib; the first phase has no incremental contract watcher. + +Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. + +Type imports and runtime contributions have different effects. `import type {}` extends only the static API. If a real calling environment omits the value contribution, the API Service must fail with an explicit "Remote not mounted" error. + +Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior. + +A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. + +Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md new file mode 100644 index 0000000000..9b2fbbd69f --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -0,0 +1,489 @@ +# Agent Note: TypeRT Gateway 定向方法调用 + +Status: proposed + +[English](2026-08-02-typert-remote-method-calls.md) | 中文 + +## Problem + +Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 + +本方案只解决一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流不使用本方案,后续分别设计。 + +直接方法调用的契约属于实现该行为的业务 Service。业务开发者应只声明哪些方法可以远程调用,而不应再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 + +Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以不同类型合并同名 Cordis `Context`。Remote 投影不能把完整 Host 声明导入消费端,也不能依赖 Browser 专属类型;未来 TUI 若复用这套编程界面,也只能看到 Remote 标记的方法。本期不实现 TUI 接入,但实现边界不得阻断这种同构复用。 + +## Proposal + +业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 + +Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 + +`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在唯一 Connection/RPC 机制之上,使用独立 `/api2` channel;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 + +## 组件和 Cordis 服务 + +| 组件 | Cordis 服务 | 本方案中的职责 | +|---|---|---| +| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | +| TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | +| Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、RPC envelope、rpcId、序列化、trust 和错误传输,并承载 `/api` 与 `/api2` 两个隔离 channel | +| Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | +| Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | +| Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | + +Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client API 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Client API namespace。 + +## 业务声明 + +普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: + +```text +export class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + // Existing business method remains unchanged. + } + + @Remote('create') + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return this.create(agent, request) + } +} +``` + +`goals` 是明确的 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过 `bindTypeRTGateway()` 的选项覆盖。 + +需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: + +```text +export class ScopedGoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @RemoteContext('agent', 'create') + remoteExportCreate(request: CreateGoalRequest): Promise { + // Runs against the goals service resolved from the Agent Context. + } +} +``` + +同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 + +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 + +## Decorator 与显式 Gateway facet + +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 + +SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 + +LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。生成过程不改写业务源码,也不向 `bindTypeRTGateway()` 偷注生成参数。 + +## Lookup 与 Remote Context 注册 + +Gateway 不内置 Agent、Session 或其他业务对象分支。对象所属包同时提供静态声明和运行时 provider: + +```text +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } +} + +ctx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + resolve: sessionId => resolveAgent(sessionId), +}) +``` + +静态声明让 TypeRT 知道 `Agent` 在 wire 上对应 `SessionId`;运行时 provider 负责把请求中的 `agentId` 解析为当前活的 `Agent` 对象。缺少任一侧时,LIB 构建或最早可解析的运行时注册直接失败。 + +Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本方案不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 + +Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 + +Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所在的 Context 取得 `SessionId`;它不枚举 Scope,也不逐个复制方法。scoped namespace 由 Cordis Service tracker 自动 rebind 到当前 Agent Context。 + +## InvocationDescriptor + +TypeRT、SRC 弱解析器、Host Gateway 和 Client API 之间只交换一种规范描述: + +```text +InvocationDescriptor { + id: '@deepseek-ai/dsh-goal#goals/create' + service: 'goals' + namespace: 'goals' + method: 'create' + implementation: 'remoteExportCreate' + invocation: direct | { context: 'agent', wire: 'agentId' } + scope?: { context: 'agent', wire: 'agentId' } + parameters: [ + { name, wire, source: json | lookup, lookup?, codec } + ] + result: codec + sourceLocation +} +``` + +`method` 是 endpoint 和 Client API 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 + +严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 + +参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 + +LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 + +descriptor 只存在于两端本地 registry。wire 上只有 `/api2` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 + +## TypeRT 运行时 registry + +```text +ctx.typert.local 当前进程自己的 Host 或 Client reflection +ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution +ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.contexts Host Context resolver 与 Client Context binder +``` + +每次注册都返回由调用方 Cordis fiber 持有的 disposer。Gateway 和 API Service 先读取当前快照再订阅变化,因此业务 Service、generated contribution、provider 和消费者可以按任意顺序加载;任一依赖 dispose 后,相关 endpoint 或方法立即失效。 + +Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 + +## 唯一类型、符号与 Zod + +Remote Client DTS 不复制业务 DTO,也不重新声明一个结构相同的影子类型。它只从不携带 Host Cordis merge 的公共纯类型 subpath 引用原始符号: + +```text +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/types' +``` + +因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 + +Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 的 `remoteExport*` 方法名 token,并在 namespace interface 的对应属性上写入 source-map segment;TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 + +TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 + +Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。 + +lookup 参数不会把 `Agent` class 暴露给消费端。Remote 投影引用 lookup 声明中的唯一 ID 类型,例如 `SessionId`;Host 内部仍以唯一的 `Agent` class symbol 完成对象解析。 + +## 三种产物与两个 TypeScript Program + +Host 与 Client 仍然只有两个独立 TypeScript Program,但 TypeRT 生成三种性质不同的产物: + +```text +Host Program +├─ typert.host.js / typert.host.d.ts +│ Host 自身的 Service、Event、Object、schema 和 inbound Gateway 信息 +└─ typert.remote-client.js / typert.remote-client.d.ts / typert.remote-client.d.ts.map + Host Remote 对任意消费环境的 wire 投影 + +Client Program +└─ typert.client.js / typert.client.d.ts + Client 自身的 Service、Event、Object 和 schema 信息 +``` + +`remote-client` 是 Host Program 的第二个 emitter,不是第三个 Program,也不是 Client 本地 face。它不包含 Host Cordis merge、Service class、Context class 或实现代码,不进入 Host 本地 reflection registry。 + +Host lib 构建负责完成严格 Host 分析并产出 Host 本地 artifact 与 Remote 消费端 artifact;Client lib 随后消费 Remote DTS。完整顺序为: + +```text +Host lib build +→ 生成 typert.host.{js,d.ts} +→ 生成各业务包 lib/typert.remote-client.{js,d.ts,d.ts.map} +→ 完成 Client lib 和 typert.client 产物 +→ Vite 构建 Web +``` + +现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 + +## `/remote` 包入口 + +每个提供 Remote 方法的业务包导出生成的 `/remote` 子路径: + +```text +"./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" +} +``` + +消费代码通过业务包本身选择能力: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +``` + +该 import 让 `.d.ts` 的 map augmentation 进入当前 TypeScript project,同时把同一契约的 JS descriptor 作为值交给运行时。未 import 的业务包不会扩展当前 project 的 Remote API 类型。 + +业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 + +仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 API Service。 + +workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。 + +## 消费端严格 API 类型 + +Remote DTS 同时扩展平面 endpoint map、direct namespace interface、namespace map 和 scoped map,而不扩展全局 Cordis `Context`: + +```text +interface TypeRTRemoteNamespace$676f616c73 { + create: ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteMap { + 'goals/create': ( + agentId: SessionId, + request: CreateGoalRequest, + ) => Promise +} + +interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace$676f616c73 +} + +interface TypeRTRemoteContextMap { + 'agent:goals/create': ( + request: CreateGoalRequest, + ) => Promise +} +``` + +`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 API 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 + +TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: + +```text +api.goals.create(agentId, request) +agent.goals.create(request) +``` + +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。本期只有 Client Agent Context 获得 `goals`,Root Context 不获得该属性;未来 TUI 复用时必须维持相同的 Scope 限制。 + +`RemoteApi` 保持平台无关,Browser Client 把它作为自己的 `ClientApi`。未来 TUI 若复用该类型,也必须通过专用 API 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 + +## Client TypeRT 与 API Gateway Client face + +一个消费环境的 TypeRT 同时维护本地信息和从其他环境导入的 Remote 信息,但两者存放在不同 registry: + +```text +TypeRT.local 当前环境自己的反射模型 +TypeRT.remotes 已导入的 Remote contribution +``` + +`@deepseek-ai/dsh-client-remotes/client` 集中加载需要的 Remote contribution: + +```text +import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import sessionsRemote from '@deepseek-ai/dsh-session/remote' + +ctx.api.mount(goalsRemote) +ctx.api.mount(sessionsRemote) +``` + +Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接依赖 Host API Gateway 或各业务 `/remote` 运行时入口。Client Remotes 自己依赖 Gateway Client face,并通过声明 re-export 把所选 Remote map 传给业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 + +`ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 + +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api2', endpoint, { args })`。 + +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api2` 调用。 + +```text +root ctx.api.goals.create(agentId, request) + → direct descriptor + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + +agent.goals.create(request) + → tracker 将 namespace Service rebind 到 agent Context + → agent binder 从 caller Context 取得 agentId + → 用 agentId 补入同一 direct descriptor 的 lookup 参数 + → ctx.connection.rpc.call('/api2', 'goals/create', { args }) +``` + +Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 + +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 可以据此创建真实函数,因此本方案不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 + +## 跨环境同构约束 + +Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 + +Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api2` RPC 调用。 + +未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 + +TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 + +Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后必须重新执行 lib build,再启动或重启 Web;本方案不在第一阶段实现 Remote contract 的增量 watch。 + +## SRC 与 LIB 运行模式 + +SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 + +例如 `@Remote('create') remoteExportCreate(agent, request)` 解析为外部方法 `create`、实现成员 `remoteExportCreate` 和两个顶层参数;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 + +SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 + +LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec 和结果 codec,并生成严格 descriptor。 + +LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 + +CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。 + +## Host Gateway 注册 + +Host Gateway 同时观察 TypeRT Remote definition 和 Cordis Service 生命周期。当某个带 `typertGateway` facet 的 Service 与同 service key 的 definition 都可用时,Gateway 注册其 endpoint;两者到达顺序不影响结果。 + +Gateway 启动时先读取 TypeRT definition 和 Cordis reflection store 的当前快照,再订阅 registry change 与 `internal/service`。它按 service key reconcile definition、活 Service 和 binding;Service 被替换或 dispose 时撤销对应 endpoint。definition、lookup provider 或 Context provider 撤销时,依赖它们的 endpoint 立即不可调用,不保留失效对象或降级为原始 ID 调用。 + +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 + +`@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 + +```text +ctx.typertGateway.invoke({ namespace, method, args }) +→ 查找本地 InvocationDescriptor 与 live receiver +→ 按参数 descriptor 读取具名 wire 字段 +→ codec 解码普通值或 lookup ID +→ lookup provider 把 ID 解析为活对象 +→ direct 使用原 Service;context 先解析 scoped Context 和 Service +→ Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) +→ result codec 编码业务结果 +``` + +`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。 + +## `/api2` 调用链 + +`/api2` 是唯一 Connection/RPC 机制上的独立协议 channel,不是 Gateway 自建的 transport。Gateway 只向 Connection 注册一个本地 handler;本期在现有 HTTP Connection 中增加这项通用 channel 能力: + +```text +ctx.connection.rpc.handle('/api2', (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) +}) +``` + +Connection Host half 从唯一 HTTP Server 取得 handle,复用同一 RPC bridge、request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: + +```text +POST /api2// +``` + +Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是: + +```json +{ + "args": { + "agentId": "session-1", + "request": { + "objective": "finish the migration" + } + } +} +``` + +完整链路为: + +```text +ctx.api.goals.create(sessionId, request) +→ Client InvocationDescriptor 编码 { args: { agentId, request } } +→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ Connection 创建 rpcId 和既有 client-request envelope +→ 当前 carrier 发送 POST /api2/goals/create +→ Connection Host half 执行 trust、反序列化和 RPC 分发 +→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ result codec 编码 +→ Connection 写入既有 RPC result 并回送相同 rpcId +→ Client result codec 验证并返回 CreateGoalResult +``` + +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 + +Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。本工作只扩展 Connection 的通用 channel 注册和调用能力,不改变现有 `/api`、trusted connection、trusted-host 或 privileged method 语义;Connection/WebSocket 迁移后续独立完成。 + +## Connection 与协议边界 + +API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、lookup、Context 和业务调用。Connection 只负责把 `/api2`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 + +`/api` 与 `/api2` 共享唯一 Connection、Server、RPC envelope 和连接生命周期,但保持协议隔离。Connection 从 HTTP 迁移到 WebSocket 时,`/api2` 从物理路径自然变成逻辑 channel;Remote payload、业务 decorator、生成的 DTS、Remote API 类型和 Agent Scope 编程界面都不变化。 + +## 包边界 + +- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 +- TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 +- TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 +- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api2` handler;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、RPC envelope、rpcId、序列化、trust 和错误传输,同时承载隔离的 `/api` 与 `/api2` channel。 +- Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 +- 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 + +## 首期实现范围 + +第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 + +本期实现 Connection 的通用第二 channel API 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 + +## Alternatives considered + +**继续使用中央 API Proxy 包。** 该方案要求业务方法、Host 路由和 Client 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。 + +**让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 TypeRT compiler 生成。 + +**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB contract pass。 + +**手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。 + +**使用 TypeScript language-service/compiler plugin 让 Client 直接理解 decorator。** 这会让编辑器、Vite、tsc、tsx 和发布消费者都依赖额外插件,接入面过大,因此生成普通 `.d.ts` 和标准 declaration map。 + +**把完整 Host DTS 导入 Client 或 TUI。** 该方案会带入 Host Service 和 Cordis interface merge,并向消费端暴露未标记方法。Remote DTS 只引用纯类型公共符号并扩展专用 Remote maps。 + +**只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。 + +**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 + +**为 Remote 新建独立 transport、HTTP route 和响应信封。** 这会复制现有 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期,并让两个 RPC 栈分别迁移,因此 `/api2` 作为独立协议 channel 复用唯一 Connection/RPC 机制。 + +## Acceptance criteria + +- Goal Service 保留既有业务方法,在类末尾通过显式 `typertGateway` 和 `@Remote('create') remoteExportCreate(...)` 新增远程出口,不维护第二份路由、codec 或 Client 方法清单。 +- 一次干净 `build:lib` 先生成 Host Remote contract,再完成 Host 和 Client 消费端编译,并在业务包 `lib` 下产生可通过 `/remote` 导入的 JS、DTS 和 DTS map。 +- 导入 `@deepseek-ai/dsh-goal/remote` 后,消费 project 获得严格的 `api.goals.create(...)` 类型;不导入时该 namespace 不进入类型;从 `create` 跳转定义会通过 declaration map 到达 Host Service 的 `remoteExportCreate` 实现。 +- Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 +- Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 +- `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 +- `/api2/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 +- `/api2` 与 `/api` 共享唯一 Connection/RPC carrier,但保持协议隔离;Remote 不直接注册 HTTP Server handle,也不定义第二套 response envelope。 +- Connection 提供通用 channel 注册和调用能力,并把 `/api2` 映射到当前 HTTP carrier;现有 `/api` 行为与 trust 语义保持不变。 +- 现有 `/api`、Connection/trusted connection、Permission/Approval 和 Session 事件流行为不因本实现改变。 + +## Risks + +Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。 + +源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。 + +SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费者必须重新执行 lib build;第一阶段没有增量 contract watch。 + +公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 + +类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 API;真实调用环境遗漏 value contribution 时,API Service 必须以明确的“Remote 未挂载”错误失败。 + +Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。 + +消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 + +Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 From 64a963da0b42f9cd389d133656f73b1936760c41 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:17:47 +0800 Subject: [PATCH 30/88] feat: add TypeRT remote gateway infrastructure --- apps/cli/composition.md | 9 + docs/capability-seams.md | 7 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 31 +- docs/module-graph.md | 14 +- package.json | 5 +- packages/bundle/base/cordis.patch.yml | 9 + packages/bundle/base/package.json | 3 + .../client/connection/src/client/index.ts | 7 + packages/client/connection/src/client/rpc.ts | 75 ++ packages/client/connection/src/index.ts | 101 +- packages/client/connection/src/rpc-host.ts | 150 +++ packages/client/connection/src/rpc.ts | 59 ++ .../connection/tests/client-apply.spec.ts | 37 + .../client/connection/tests/node-half.spec.ts | 92 +- .../client/runtime/tests/client-apply.spec.ts | 3 + .../client/runtime/tests/wire-events.spec.ts | 3 + packages/client/tsdown.client.ts | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 46 +- packages/core/agent/package.json | 8 + packages/core/agent/src/index.ts | 25 + packages/core/agent/tests/agent.spec.ts | 26 + packages/core/agent/tsconfig.json | 3 + packages/core/session/package.json | 3 + packages/core/session/src/index.ts | 16 + packages/core/session/tests/typert.spec.ts | 26 + packages/core/session/tsconfig.json | 3 + packages/host/api-gateway/README.i18n.yaml | 6 + packages/host/api-gateway/README.md | 36 + packages/host/api-gateway/README.zh.md | 36 + packages/host/api-gateway/package.json | 68 ++ packages/host/api-gateway/src/client/index.ts | 370 ++++++++ packages/host/api-gateway/src/index.ts | 604 ++++++++++++ packages/host/api-gateway/src/invariant.ts | 30 + packages/host/api-gateway/src/types.ts | 52 ++ .../host/api-gateway/tests/client.spec.ts | 222 +++++ .../host/api-gateway/tests/gateway.spec.ts | 795 ++++++++++++++++ packages/host/api-gateway/tsconfig.json | 27 + packages/host/api-gateway/tsdown.config.ts | 3 + packages/host/apiproxy/src/api/index.ts | 5 + packages/typert/generator/package.json | 1 + packages/typert/generator/src/analyzer.ts | 869 +++++++++++++++++- .../typert/generator/src/cordis-catalog.ts | 2 +- packages/typert/generator/src/emitter.ts | 538 ++++++++++- packages/typert/generator/src/model.ts | 55 ++ packages/typert/generator/src/renderer.ts | 101 +- .../typert/generator/src/tsdown-plugin.ts | 79 +- packages/typert/generator/src/workspace.ts | 42 +- .../__snapshots__/type-model.spec.ts.snap | 5 + .../tests/fixtures/remote-model/package.json | 5 + .../remote-model/packages/domain/package.json | 9 + .../remote-model/packages/domain/src/index.ts | 19 + .../remote-model/packages/domain/src/types.ts | 2 + .../packages/domain/tsconfig.json | 11 + .../remote-model/packages/remote/package.json | 24 + .../remote-model/packages/remote/src/index.ts | 30 + .../remote-model/packages/remote/src/types.ts | 20 + .../packages/remote/tsconfig.json | 14 + .../fixtures/remote-model/tsconfig.base.json | 20 + .../fixtures/remote-model/tsconfig.host.json | 8 + .../fixtures/remote-model/type-meta.d.ts | 45 + .../generator/tests/remote-model.spec.ts | 486 ++++++++++ .../generator/tests/schema-emitter.spec.ts | 238 ++++- .../generator/tests/tools-catalog.spec.ts | 2 +- .../generator/tests/tsdown-plugin.spec.ts | 81 ++ .../typert/generator/tests/type-model.spec.ts | 98 ++ packages/typert/loader/src/index.ts | 91 +- packages/typert/loader/tests/loader.spec.ts | 210 +++++ packages/typert/registry/package.json | 15 + packages/typert/registry/src/client/index.ts | 15 + packages/typert/registry/src/index.ts | 220 +---- packages/typert/registry/src/service.ts | 584 ++++++++++++ packages/typert/registry/src/types.ts | 8 + packages/typert/registry/tests/typert.spec.ts | 184 +++- packages/typert/registry/tsconfig.json | 3 + packages/typert/registry/tsdown.config.ts | 26 +- packages/typert/type-meta/README.i18n.yaml | 6 + packages/typert/type-meta/README.md | 33 + packages/typert/type-meta/README.zh.md | 33 + packages/typert/type-meta/package.json | 42 + packages/typert/type-meta/src/index.ts | 223 +++++ packages/typert/type-meta/src/invariant.ts | 30 + packages/typert/type-meta/src/types.ts | 358 ++++++++ .../type-meta/tests/fixtures/source-launch.ts | 29 + .../typert/type-meta/tests/type-meta.spec.ts | 132 +++ packages/typert/type-meta/tsconfig.json | 21 + pnpm-lock.yaml | 61 ++ scripts/client-bundle-purity.spec.ts | 7 + scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 11 +- .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 10 +- tsconfig.client.json | 2 + tsconfig.host.json | 2 + tsdown.config.ts | 4 + tsdown.typert-host.config.ts | 20 + vitest.config.ts | 30 +- 98 files changed, 7812 insertions(+), 444 deletions(-) create mode 100644 packages/client/connection/src/client/rpc.ts create mode 100644 packages/client/connection/src/rpc-host.ts create mode 100644 packages/client/connection/src/rpc.ts create mode 100644 packages/core/session/tests/typert.spec.ts create mode 100644 packages/host/api-gateway/README.i18n.yaml create mode 100644 packages/host/api-gateway/README.md create mode 100644 packages/host/api-gateway/README.zh.md create mode 100644 packages/host/api-gateway/package.json create mode 100644 packages/host/api-gateway/src/client/index.ts create mode 100644 packages/host/api-gateway/src/index.ts create mode 100644 packages/host/api-gateway/src/invariant.ts create mode 100644 packages/host/api-gateway/src/types.ts create mode 100644 packages/host/api-gateway/tests/client.spec.ts create mode 100644 packages/host/api-gateway/tests/gateway.spec.ts create mode 100644 packages/host/api-gateway/tsconfig.json create mode 100644 packages/host/api-gateway/tsdown.config.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts create mode 100644 packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json create mode 100644 packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts create mode 100644 packages/typert/generator/tests/remote-model.spec.ts create mode 100644 packages/typert/registry/src/client/index.ts create mode 100644 packages/typert/registry/src/service.ts create mode 100644 packages/typert/type-meta/README.i18n.yaml create mode 100644 packages/typert/type-meta/README.md create mode 100644 packages/typert/type-meta/README.zh.md create mode 100644 packages/typert/type-meta/package.json create mode 100644 packages/typert/type-meta/src/index.ts create mode 100644 packages/typert/type-meta/src/invariant.ts create mode 100644 packages/typert/type-meta/src/types.ts create mode 100644 packages/typert/type-meta/tests/fixtures/source-launch.ts create mode 100644 packages/typert/type-meta/tests/type-meta.spec.ts create mode 100644 packages/typert/type-meta/tsconfig.json create mode 100644 tsdown.typert-host.config.ts diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 1da393bd06..0246f6163f 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -18,6 +18,12 @@ flowchart LR cfg --> plugin_dsh_base_llm plugin_dsh_base_session["session
@deepseek-ai/dsh-session"] cfg --> plugin_dsh_base_session + plugin_dsh_base_typert["typert
@deepseek-ai/dsh-typert-registry"] + cfg --> plugin_dsh_base_typert + plugin_dsh_base_typert_loader["typert-loader
@deepseek-ai/dsh-typert-loader"] + cfg --> plugin_dsh_base_typert_loader + plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-host-api-gateway"] + cfg --> plugin_dsh_base_typert_gateway plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title plugin_dsh_base_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] @@ -159,6 +165,9 @@ flowchart LR | `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | +| `typert` | `@deepseek-ai/dsh-typert-registry` | +| `typert-loader` | `@deepseek-ai/dsh-typert-loader` | +| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6a8f7943c2..18839bf3c2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -32,6 +32,8 @@ flowchart LR pkg_typert_registry["typert-registry"] svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] + pkg_api_gateway["api-gateway"] + svc_typertGateway["ctx.typertGateway
TypeRT Host invocation gateway"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -171,6 +173,7 @@ flowchart LR pkg_acp --> svc_approval pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop + pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval pkg_bash --> svc_bash pkg_bash_env --> svc_bashEnv @@ -347,6 +350,7 @@ flowchart LR svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web + svc_typert --> pkg_api_gateway svc_typert --> pkg_typert_loader svc_userInteraction --> pkg_tool_ask_user svc_web --> pkg_tool_web @@ -363,7 +367,8 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | -| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. | +| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `api-gateway` | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | +| `ctx.typertGateway` | `core` | `api-gateway` | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9fa6e8bb2..5728ac4bed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -291,7 +291,7 @@ Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli- ## `@deepseek-ai/dsh-client-connection` -Requires: `httpServer` · `apiProxy` +Requires: `httpServer` ```ts config-catalog /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -308,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -2548,6 +2548,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) @@ -2563,6 +2564,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) +- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) @@ -2620,4 +2622,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) +- `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) +- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 955adbe234..348d334e9f 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -542,7 +542,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:73`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -563,7 +563,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -586,7 +586,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:95`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -606,7 +606,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) ## `settings/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b2f667681d..0a9af0bae5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -1748,7 +1748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -2527,16 +2527,17 @@ Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/i ## `ctx.typert` — `TypertRegistry` -Registry of generated schemas and package reflection. +Registry of generated schemas, package reflection, invocations, and Remote dependency providers. ```ts cordis-catalog /** * Register one generated contribution atomically for the calling fiber. - * Duplicate package-face identities or schema keys reject the whole batch. - * @param contribution - generated schemas and package metadata. + * Duplicate package-face identities, schemas, invocation ids, or endpoints + * reject the whole batch. + * @param contribution - generated schemas, reflection, and Host invocations. * @returns the exact effect disposer that removes this contribution. */ -register(contribution: TypertContribution): () => void +register(contribution: TypertContribution): TypeRTDisposer /** * Look up one schema by `#`. @@ -2584,7 +2585,23 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts) +Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts) + +## `ctx.typertGateway` — `TypertGatewayService` + +Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers. + +```ts cordis-catalog +/** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ +async invoke(request: InvokeRemoteRequest): Promise +``` + +Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a7ae5c8d2..fd9ac036a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -211,6 +211,7 @@ flowchart TD end subgraph group_host["packages/host"] pkg_frontend_static["frontend-static"] + pkg_host_api_gateway["host-api-gateway"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] @@ -272,6 +273,7 @@ flowchart TD pkg_session_telemetry_otel["session-telemetry-otel"] end subgraph group_typert["packages/typert"] + pkg_type_meta["type-meta"] pkg_typert_generator["typert-generator"] pkg_typert_loader["typert-loader"] pkg_typert_registry["typert-registry"] @@ -311,6 +313,7 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants pkg_llm --> pkg_brand @@ -374,6 +377,7 @@ flowchart TD pkg_session --> pkg_invariants pkg_session --> pkg_llm pkg_session --> pkg_scope + pkg_session --> pkg_type_meta pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope @@ -420,6 +424,9 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_host_api_gateway --> pkg_client_connection + pkg_host_api_gateway --> pkg_invariants + pkg_host_api_gateway --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -434,6 +441,7 @@ flowchart TD pkg_agent --> pkg_scope pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_type_meta pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox pkg_bash --> pkg_subprocess @@ -1154,6 +1162,7 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | +| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`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) | @@ -1175,7 +1184,7 @@ flowchart TD | [`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) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`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) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`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) | @@ -1186,10 +1195,11 @@ flowchart TD | [`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) | +| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`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) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/package.json b/package.json index 7bd84db93a..9d0cac6d5e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ ], "scripts": { "build": "npm run build:lib && npm run build:web", - "build:lib": "tsc -b && tsdown", + "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:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index f0577552cc..0b1cc43a50 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -34,6 +34,15 @@ - id: session name: '@deepseek-ai/dsh-session' + - id: typert + name: '@deepseek-ai/dsh-typert-registry' + + - id: typert-loader + name: '@deepseek-ai/dsh-typert-loader' + + - id: typert-gateway + name: '@deepseek-ai/dsh-host-api-gateway' + - id: session-title name: '@deepseek-ai/dsh-session-title' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index c6519171ca..2ec17d9c66 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-host-api-gateway": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", @@ -95,6 +96,8 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-loader": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 67b47b06c6..521e54160e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' +import type { ClientConnectionRpc } from '../rpc.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { @@ -36,6 +38,7 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. export type { ConnectionConfig, ConnectionSinks, ConnectionState } +export type { ClientConnectionRpc } from '../rpc.ts' /** Required services (none — this is the wire root). */ @@ -51,6 +54,8 @@ export interface ConnectionHandle { readonly api: IApiClient /** Whether the current page authority is loopback; non-browser contexts default to true. */ readonly isLoopback: boolean + /** Generic logical RPC channels over the same Connection transport. */ + readonly rpc: ClientConnectionRpc /** * Start the connect/pump/reconnect loop with the consumer's frame sinks. * One consumer owns the streams (the runtime object layer); a second call @@ -70,10 +75,12 @@ export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() + const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc() let started = false const handle: ConnectionHandle = { api, isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), + rpc, start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts new file mode 100644 index 0000000000..36e16426b2 --- /dev/null +++ b/packages/client/connection/src/client/rpc.ts @@ -0,0 +1,75 @@ +/** Browser caller for generic Connection unary RPC channels. */ + +import { + RpcId, + serverResponseSchema, + type ClientRequest, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ClientConnectionRpc } from '../rpc.ts' + +const INTERNAL_BASE = 'http://dsh.internal' +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** + * Create the browser-backed generic RPC caller. + * @returns caller that owns request correlation and response-envelope validation. + */ +export function createWebConnectionRpc(): ClientConnectionRpc { + return { + async call(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint) + const rpcId = RpcId(crypto.randomUUID()) + const message: ClientRequest = { + type: 'client-request', + rpcId, + method: endpoint, + payload, + } + const response = await globalThis.fetch( + new URL(`${channel}/${endpoint}`, resolveBase()), + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(message), + ...signal === undefined ? {} : { signal }, + }, + ) + if (!response.ok) { + throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) + } + const full = serverResponseSchema.parse(await response.json()) + if (full.rpcId !== rpcId) { + throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) + } + return full.result + }, + } +} + +/** + * Create the fixture-mode caller, where no Host Remote registry exists. + * @returns caller that rejects every generic Remote invocation. + */ +export function createUnavailableConnectionRpc(): ClientConnectionRpc { + return { + call(channel, endpoint) { + return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`)) + }, + } +} + +function resolveBase(): string { + const location = (globalThis as { location?: { origin?: string } }).location + return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE +} + +function assertTarget(channel: string, endpoint: string): void { + const segments = endpoint.split('/') + if (!CHANNEL_PATTERN.test(channel) + || segments.length === 0 + || segments.some(segment => + segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { + throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`) + } +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 2e27a78d70..d8b6ef8846 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -7,15 +7,25 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { HostConnectionService } from './rpc-host.ts' import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' +export type { + ConnectionRpcAuthority, + ConnectionRpcHandler, + ConnectionRpcHandlerOptions, + HostConnectionHandle, + HostConnectionRpc, +} from './rpc.ts' +export { HostConnectionService } from './rpc-host.ts' + export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the route. */ -export const inject = ['httpServer', 'apiProxy'] +/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */ +export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { @@ -83,49 +93,52 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) - const apiHandler = toFetchHandler(ctx.apiProxy) - const downlinks = new WebSocketDownlinks(ctx.apiProxy) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) - ? isTrustedApiRequest(req, []) - : isTrustedApiRequest(req, trustedHosts) - if (!allowed) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) - res.end('upgrade required') - return - } - await bridge(req, res, apiHandler) - }, - } - ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - const registerDownlink = ( - path: string, - handle: WebUpgradeRoute['handler'], - ): void => { - ctx.effect(() => ctx.httpServer.registerUpgrade({ - path, - handler: (req, socket, head) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - rejectWebSocketUpgrade(socket) + new HostConnectionService(ctx, trustedHosts) + ctx.inject(['apiProxy'], (apiCtx) => { + const apiHandler = toFetchHandler(apiCtx.apiProxy) + const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + const method = pathname.startsWith(`${API_PATH}/`) + ? pathname.slice(API_PATH.length + 1) + : undefined + const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) + ? isTrustedApiRequest(req, []) + : isTrustedApiRequest(req, trustedHosts) + if (!allowed) { + res.writeHead(403) + res.end('forbidden') return } - return handle(req, socket, head) + if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) + res.end('upgrade required') + return + } + await bridge(req, res, apiHandler) }, - }), `client-connection: ${path} WebSocket`) - } - ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') - registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) - registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) + } + apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route') + const registerDownlink = ( + path: string, + handle: WebUpgradeRoute['handler'], + ): void => { + apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({ + path, + handler: (req, socket, head) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + rejectWebSocketUpgrade(socket) + return + } + return handle(req, socket, head) + }, + }), `client-connection: ${path} WebSocket`) + } + apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') + registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) + registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) + }) } diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts new file mode 100644 index 0000000000..be9eedca8f --- /dev/null +++ b/packages/client/connection/src/rpc-host.ts @@ -0,0 +1,150 @@ +/** Host registry and HTTP adapter for generic Connection RPC channels. */ + +import { Context, Service } from 'cordis' +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { + clientRequestSchema, + RpcId, + type ClientRequest, + type RpcError, + type RpcId as RpcIdType, + type ServerResponse as RpcServerResponse, +} from '@deepseek-ai/dsh-host-apiproxy/api' +import { bridge } from './http-bridge.ts' +import { isTrustedApiRequest } from './api-request-trust.ts' +import type { + ConnectionRpcHandler, + ConnectionRpcHandlerOptions, + HostConnectionHandle, + HostConnectionRpc, +} from './rpc.ts' + +const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** Host Connection service whose channel registrations belong to the caller fiber. */ +export class HostConnectionService extends Service implements HostConnectionHandle { + /** + * Provide the Host half over the active HTTP server. + * @param ctx - owning Connection plugin context. + * @param trustedHosts - deployment authorities accepted by trusted-host channels. + */ + constructor(ctx: Context, private readonly trustedHosts: readonly string[]) { + super(ctx, 'connection') + } + + /** Generic channel registry scoped to the Context reading this service. */ + get rpc(): HostConnectionRpc { + const owner = this.ctx + return { + handle: (channel, handler, options) => this.register(owner, channel, handler, options), + } + } + + private register( + owner: Context, + channel: string, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise { + assertChannel(channel) + const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts + const fetchHandler = rpcFetchHandler(channel, handler) + const route: WebRoute = { + kind: 'prefix', + path: channel, + handler: async (req, res) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, fetchHandler) + }, + } + return owner.effect( + () => owner.httpServer.register(route), + `client-connection: ${channel} rpc channel`, + ) + } +} + +function rpcFetchHandler( + channel: string, + handler: ConnectionRpcHandler, +): { fetch: typeof fetch } { + return { + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = input instanceof Request ? input : new Request(input, init) + const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + if (request.method !== 'POST' || endpoint === undefined) { + return new Response('not found', { status: 404 }) + } + + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/json') { + return new Response('content type must be application/json', { status: 415 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return new Response('body is not JSON', { status: 400 }) + } + + const envelope = clientRequestSchema.safeParse(body) + if (!envelope.success) { + const rawId = (body as { rpcId?: unknown } | null)?.rpcId + const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID + return errorResponse(rpcId, { + code: 'bad-request', + message: 'invalid client-request message', + details: { issues: envelope.error.issues }, + }) + } + const message: ClientRequest = envelope.data + if (message.method !== endpoint) { + return errorResponse(message.rpcId, { + code: 'bad-request', + message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, + details: { issues: [] }, + }) + } + + try { + const result = await handler(endpoint, message.payload, request.signal) + return fullResponse(message.rpcId, result) + } catch (error) { + return new Response(`handler failure: ${String(error)}`, { status: 500 }) + } + }, + } +} + +function endpointFromPath(channel: string, pathname: string): string | undefined { + if (!pathname.startsWith(`${channel}/`)) return undefined + const endpoint = pathname.slice(channel.length + 1) + const segments = endpoint.split('/') + if (segments.length === 0 || segments.some(segment => + segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { + return undefined + } + return endpoint +} + +function errorResponse(rpcId: RpcIdType, error: RpcError): Response { + return fullResponse(rpcId, { ok: false, error }) +} + +function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response { + const body: RpcServerResponse = { type: 'server-response', rpcId, result } + return Response.json(body) +} + +function assertChannel(channel: string): void { + if (!CHANNEL_PATTERN.test(channel) || channel === '/api') { + throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`) + } +} diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts new file mode 100644 index 0000000000..ab68783724 --- /dev/null +++ b/packages/client/connection/src/rpc.ts @@ -0,0 +1,59 @@ +/** Generic unary RPC contracts shared by the Host and Client Connection halves. */ + +import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' + +/** Trust fence applied before a Host RPC channel reaches its handler. */ +export type ConnectionRpcAuthority = 'trusted-host' | 'loopback' + +/** Registration policy for one logical RPC channel. */ +export interface ConnectionRpcHandlerOptions { + /** Browser authority accepted by every endpoint in this channel. */ + readonly authority: ConnectionRpcAuthority +} + +/** Handler invoked after Connection has decoded the transport envelope. */ +export type ConnectionRpcHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise> + +/** Host registry for logical RPC channels carried by the current transport. */ +export interface HostConnectionRpc { + /** + * Register one absolute channel prefix and its trust policy. + * @param channel - absolute logical channel such as `/api2`. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @param options - channel trust policy. + * @returns asynchronous disposer removing the channel and its physical route. + */ + handle( + channel: string, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise +} + +/** Host `ctx.connection` shape consumed by transport-independent adapters. */ +export interface HostConnectionHandle { + /** Generic RPC channel registry. */ + readonly rpc: HostConnectionRpc +} + +/** Client caller for logical RPC channels carried by the current transport. */ +export interface ClientConnectionRpc { + /** + * Call one endpoint through an already registered logical channel. + * @param channel - absolute logical channel such as `/api2`. + * @param endpoint - channel-relative endpoint such as `goals/create`. + * @param payload - channel-owned request payload. + * @param signal - optional caller cancellation. + * @returns the existing RPC success/error result; correlation stays inside Connection. + */ + call( + channel: string, + endpoint: string, + payload: unknown, + signal?: AbortSignal, + ): Promise> +} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 524983fb4f..d93844a2b8 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -203,4 +203,41 @@ describe('connection client apply', () => { expect(sockets).toHaveLength(1) expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) + + it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + const handle = await mount() + const original = globalThis.fetch + const seen: { url: string; body: unknown }[] = [] + globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body') + const body = JSON.parse(init.body) as { rpcId: string } + seen.push({ url, body }) + return Response.json({ + type: 'server-response', + rpcId: body.rpcId, + result: { ok: true, value: { ref: 'goal-1' } }, + }) + } + try { + await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } })) + .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) + } finally { + globalThis.fetch = original + } + expect(seen).toHaveLength(1) + expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create') + expect(seen[0]?.body).toMatchObject({ + type: 'client-request', + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }) + }) + + it('keeps generic Remote calls unavailable in the client-only fixture', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 3015881d2f..af85d4e510 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts' +import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' /** Structural httpServer fake recording both route registries. */ function fakeHttpServer( @@ -17,6 +18,9 @@ function fakeHttpServer( ): Pick { return { register(route) { + if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) { + throw new Error(`duplicate route ${route.path}`) + } routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, @@ -36,15 +40,25 @@ function fakeRequest(headers: Record, url = `${API_PATH}/session return request } +/** JSON POST carrying a complete client-request envelope. */ +function fakePost(headers: Record, url: string, body: unknown): IncomingMessage { + const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage + Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } }) + return request +} + /** Response recorder compatible with both the fence's short-circuit and the bridge. */ function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { const state: { status?: number; body?: unknown } = {} + const chunks: Buffer[] = [] const response = Object.assign(new EventEmitter(), { writableEnded: false, writeHead(value: number) { state.status = value; return this }, - write() { return true }, + write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true }, end(this: { writableEnded: boolean }, value?: unknown) { - if (value !== undefined) state.body = value + if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value)) + else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies') + if (chunks.length > 0) state.body = Buffer.concat(chunks).toString() this.writableEnded = true return this }, @@ -173,6 +187,78 @@ describe('connection node half', () => { expect(declared.state.status).toBe(404) await dispose() }) + + it('provides a disposable generic RPC channel without requiring apiProxy', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(routes).toHaveLength(0) + + const connection = ctx.get('connection') as HostConnectionHandle + const calls: unknown[] = [] + const remove = connection.rpc.handle('/api2', async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, value: { accepted: true } } + }, { authority: 'trusted-host' }) + const route = routes.find(candidate => candidate.path === '/api2') + expect(route).toBeDefined() + + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-api2'), + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + } + const result = fakeResponse() + await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response) + expect(result.state.status).toBe(200) + expect(JSON.parse(String(result.state.body))).toEqual({ + type: 'server-response', + rpcId: 'rpc-api2', + result: { ok: true, value: { accepted: true } }, + }) + expect(calls).toEqual([{ + endpoint: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }]) + + expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + authority: 'trusted-host', + })).toThrow(/duplicate route/) + await remove() + expect(routes).toHaveLength(0) + await fiber.dispose() + }) + + it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) + await fiber.await() + const connection = ctx.get('connection') as HostConnectionHandle + const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + authority: 'trusted-host', + }) + const route = routes[0]! + + const denied = fakeResponse() + await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) + expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + + const badEnvelope = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { + type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, + }), badEnvelope.response) + expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({ + rpcId: 'rpc-bad', + result: { ok: false, error: { code: 'bad-request' } }, + }) + await remove() + await fiber.dispose() + }) }) describe('connection node half over a real HTTP server', () => { diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e5691a0619..14e51fae8e 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -27,6 +27,9 @@ async function mount(): Promise { const handle: ConnectionHandle = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 21e7f1fc06..f081eb54c1 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -21,6 +21,9 @@ async function mount(): Promise { const handle: ConnectionHandle = { api, isLoopback: true, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 1e45991080..74facbd69b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' */ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +/** Generated descriptor/codec contribution with no shared runtime identity. */ +const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ + /** * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ @@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf resolveId(source: string) { if (!source.startsWith('@deepseek-ai/')) return null if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins - if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point + if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point throw new Error( - `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — ` + `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — ` + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)', ) }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2bf1c1b2d9..b8da6049e8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1118,11 +1118,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'typert', - summary: 'Registry of generated schemas and package reflection.', + summary: 'Registry of generated schemas, package reflection, invocations, and Remote dependency providers.', methods: [ { - signature: 'register(contribution: TypertContribution): () => void', - jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */', + signature: 'register(contribution: TypertContribution): TypeRTDisposer', + jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities, schemas, invocation ids, or endpoints\n * reject the whole batch.\n * @param contribution - generated schemas, reflection, and Host invocations.\n * @returns the exact effect disposer that removes this contribution.\n */', }, { signature: 'get(key: string): TypertSchemaRecord | undefined', @@ -1150,6 +1150,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'typertGateway', + summary: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers.', + methods: [ + { + signature: 'async invoke(request: InvokeRemoteRequest): Promise', + jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */', + }, + ], + }, { key: 'userInteraction', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', @@ -2057,6 +2067,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InvariantInstaller', declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise;\n readonly inject?: Inject;\n}', }, + { + name: 'InvocationDescriptor', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + }, + { + name: 'InvocationParameterDescriptor', + declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n}', + }, + { + name: 'InvocationSourceLocation', + declaration: 'export interface InvocationSourceLocation {\n readonly file: string;\n readonly line: number;\n readonly column: number;\n}', + }, + { + name: 'InvokeRemoteRequest', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n}', + }, { name: 'JsonSchemaNode', declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}', @@ -3037,9 +3063,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TurnEndReasonMap', declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, + { + name: 'TypeRTCodec', + declaration: 'export type TypeRTCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly schema: TypeRTSchema;\n} | {\n readonly mode: \'src-json\';\n};', + }, { name: 'TypertContribution', - declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}', + declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}', + }, + { + name: 'TypeRTDisposer', + declaration: 'export type TypeRTDisposer = () => Promise;', }, { name: 'TypertDocTag', @@ -3077,6 +3111,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertSchema', declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}', }, + { + name: 'TypeRTSchema', + declaration: 'export interface TypeRTSchema {\n parse(value: unknown): Output;\n}', + }, { name: 'TypertSchemaFilter', declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 2e204bc7f0..9f64d33e75 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -30,6 +35,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -38,6 +44,8 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 55cb94d8f9..8f316f75dc 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -12,6 +12,7 @@ import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' @@ -20,6 +21,16 @@ export * from './llm-target.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } + + interface TypeRTContextMap { + agent: TypeRTContext + } +} + declare module 'cordis' { interface Context { agents: AgentRegistry @@ -251,6 +262,20 @@ export class AgentRegistry extends Service { constructor(ctx: Context) { super(ctx, 'agents') + ctx.inject(['typert'], (typeCtx) => { + typeCtx.typert.lookups.register('agent', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId), + }) + typeCtx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId)?.ctx, + }) + }) // The `ctx.agent` DX accessor: default `undefined` on every context, so a // plain plugin context reads cleanly instead of hitting the Cordis // unknown-property throw. Each Agent.ctx shadows it with an own property diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index e80d575aeb..643a3a49a6 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -6,6 +6,7 @@ import AgentRegistry, { agentEvents, Inbox, } from '@deepseek-ai/dsh-agent' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Agent, @@ -142,6 +143,31 @@ describe('Inbox', () => { }) describe('AgentRegistry', () => { + it('contributes Agent lookup and scoped Context providers while TypeRT is live', async () => { + const ctx = new Context() + const agentFiber = ctx.plugin(AgentRegistry) + await agentFiber + await ctx.plugin(TypertRegistry) + const agent = stubAgent('remote-agent') + const disposeAgent = ctx.agents.register(agent) + + const lookup = ctx.typert.lookups.get('agent') + expect(lookup).toMatchObject({ + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + }) + expect(lookup?.resolve(agent.id)).toBe(agent) + expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx) + + disposeAgent() + expect(lookup?.resolve(agent.id)).toBeUndefined() + await agentFiber.dispose() + expect(ctx.typert.lookups.get('agent')).toBeUndefined() + expect(ctx.typert.contexts.getHost('agent')).toBeUndefined() + }) + it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 1561175ed9..31d38b6017 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 83be69528e..04aa221573 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -45,6 +46,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d250998624..3f73242958 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,6 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' +import type { TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { deriveEventMessage, SurfaceManager } from './surface.ts' @@ -105,6 +106,12 @@ declare module 'cordis' { } } +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + session: TypeRTLookup + } +} + /** Validate and freeze one detached creation header in place. */ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { if (input === null || typeof input !== 'object' || Array.isArray(input)) { @@ -803,6 +810,15 @@ export class SessionStore extends Service { constructor(ctx: Context) { super(ctx, 'sessions') + ctx.inject(['typert'], (typeCtx) => { + typeCtx.typert.lookups.register('session', { + parameter: 'session', + wire: 'sessionId', + hostTypeSymbol: '@deepseek-ai/dsh-session#Session', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resolve: sessionId => this.get(sessionId), + }) + }) } /** diff --git a/packages/core/session/tests/typert.spec.ts b/packages/core/session/tests/typert.spec.ts new file mode 100644 index 0000000000..e1e2b32d68 --- /dev/null +++ b/packages/core/session/tests/typert.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' + +describe('Session TypeRT provider', () => { + it('contributes live Session lookup in either service load order', async () => { + const ctx = new Context() + const sessionFiber = ctx.plugin(SessionStore) + await sessionFiber + await ctx.plugin(TypertRegistry) + const session = ctx.sessions.create(SessionId('remote-session')) + + const lookup = ctx.typert.lookups.get('session') + expect(lookup).toMatchObject({ + parameter: 'session', + wire: 'sessionId', + hostTypeSymbol: '@deepseek-ai/dsh-session#Session', + wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + }) + expect(lookup?.resolve(session.id)).toBe(session) + + await sessionFiber.dispose() + expect(ctx.typert.lookups.get('session')).toBeUndefined() + }) +}) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index 253a1c8793..076ff73d9f 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" } ] } diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml new file mode 100644 index 0000000000..2abe47e0d3 --- /dev/null +++ b/packages/host/api-gateway/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/host/api-gateway/README.md +README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176 +README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md new file mode 100644 index 0000000000..3ef926ace2 --- /dev/null +++ b/packages/host/api-gateway/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-host-api-gateway + +English | [中文](README.zh.md) + +Two-sided Remote control for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-host-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave transport, request correlation, trust, and response envelopes to Connection. + +## Host service: `TypertGatewayService` (ctx key: `typertGateway`) + +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services declare participation with `bindTypeRTGateway()` and `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md). + +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. + +The Host entry registers the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. + +## Client service: `ClientApi` (ctx key: `api`) + +`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. + +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. + +Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. + +## Model Experience + +None, as the package dispatches application calls and registers no prompt, tool, or session event. + +#### KV Cache effect + +No direct effect; invoked business Services own any model-visible result. + +## Known Limitations and Deferred Work + +- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers. +- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. +- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. +- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md new file mode 100644 index 0000000000..77b8b8a87d --- /dev/null +++ b/packages/host/api-gateway/README.zh.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-host-api-gateway + +[English](README.md) | 中文 + +为 Host 与 Client 两侧的 Cordis 环境提供 Remote 控制。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-host-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将传输、请求关联、信任和响应封装交由 Connection 处理。 + +## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) + +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务调用 `bindTypeRTGateway()` 并使用 [`dsh-type-meta`](../../typert/type-meta/README.md) 提供的 `@Remote` 或 `@RemoteContext` 装饰器,以显式声明接入。 + +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 + +Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 + +## Client 服务:`ClientApi`(ctx key:`api`) + +`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 + +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 + +生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 + +## 模型体验 + +无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。 + +#### KV Cache 影响 + +无直接影响;被调用的业务服务负责产生任何模型可见结果。 + +## 已知限制与延期工作 + +- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 +- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 +- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 diff --git a/packages/host/api-gateway/package.json b/packages/host/api-gateway/package.json new file mode 100644 index 0000000000..3f3c905f1d --- /dev/null +++ b/packages/host/api-gateway/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-host-api-gateway", + "description": "Host dispatcher and Client API for TypeRT Remote invocations", + "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" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "cordis": "^4.0.0-rc.7", + "zod": "^4.4.3" + } +} diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts new file mode 100644 index 0000000000..57116db2cf --- /dev/null +++ b/packages/host/api-gateway/src/client/index.ts @@ -0,0 +1,370 @@ +/** + * Client projection of generated TypeRT Remote descriptors. Contributions + * install concrete namespace methods; no JavaScript Proxy participates in + * lookup, invocation, or type exposure. + */ + +import { Service } from 'cordis' +import type { Context } from 'cordis' +import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { + InvocationDescriptor, + TypeRTCodec, + TypeRTDisposer, + TypeRTRemoteContribution, + TypeRTRemoteNamespaceMap, +} from '@deepseek-ai/dsh-type-meta' + +type RemoteMethod = (...args: unknown[]) => Promise + +interface MountToken { + active: boolean + readonly abort: AbortController +} + +interface DirectNamespaceRecord { + readonly value: Record + readonly tokens: Map +} + +interface ScopedNamespaceRecord { + readonly service: ScopedRemoteNamespace + readonly tokens: Map +} + +interface ScopedProjection { + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + readonly parameterIndex?: number +} + +/** Typed API service augmented by generated direct Remote namespaces. */ +export interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} + +declare module 'cordis' { + interface Context { + /** Generated direct Remote namespaces selected by the Client assembly. */ + api: ClientApi + } +} + +/** Required Client services: the TypeRT registry and the existing Connection carrier. */ +export const inject = ['typert', 'connection'] + +/** + * Install the typed Client API service. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new ClientApiService(ctx) +} + +class ClientApiService extends Service implements ClientApi { + private readonly ownerCtx: Context + private readonly direct = new Map() + private readonly scoped = new Map() + + constructor(ctx: Context) { + super(ctx, 'api') + this.ownerCtx = ctx + } + + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer { + this.validateContribution(contribution) + const callerCtx = this.ctx + const disposeRemote = callerCtx.typert.remotes.register(contribution) + let disposeMethods: () => void | Promise + try { + disposeMethods = callerCtx.effect(() => { + const installed = contribution.descriptors.map(descriptor => this.install(descriptor)) + return () => { + for (const dispose of installed.reverse()) dispose() + } + }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) + } catch (error) { + disposeRemote().catch(() => {}) + throw error + } + return async () => { + await Promise.all([disposeMethods(), disposeRemote()]) + } + } + + private validateContribution(contribution: TypeRTRemoteContribution): void { + const direct = new Map>() + const scoped = new Map>() + const add = ( + table: Map>, + descriptor: InvocationDescriptor, + kind: 'direct' | 'scoped', + ): void => { + const methods = table.get(descriptor.namespace) ?? new Set() + if (methods.has(descriptor.method)) { + throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`) + } + methods.add(descriptor.method) + table.set(descriptor.namespace, methods) + const live = kind === 'direct' + ? this.direct.get(descriptor.namespace)?.tokens + : this.scoped.get(descriptor.namespace)?.tokens + if (live?.has(descriptor.method) === true) { + throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`) + } + } + for (const descriptor of contribution.descriptors) { + requireStrictDescriptor(descriptor) + if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct') + if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped') + } + for (const namespace of direct.keys()) { + if (!this.direct.has(namespace) && namespace in this) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`) + } + } + for (const [namespace, methods] of scoped) { + const record = this.scoped.get(namespace) + if (record !== undefined) { + for (const method of methods) record.service.assertMethodAvailable(method) + } else if (this.ownerCtx.reflect.props[namespace] !== undefined) { + throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } + } + } + + private install(descriptor: InvocationDescriptor): () => void { + const token: MountToken = { active: true, abort: new AbortController() } + const installed: (() => void)[] = [] + if (descriptor.invocation.kind === 'direct') { + installed.push(this.installDirect(descriptor, token)) + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + return () => { + if (!token.active) return + token.active = false + for (const dispose of installed.reverse()) dispose() + token.abort.abort() + } + } + + private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { + let namespace = this.direct.get(descriptor.namespace) + if (namespace === undefined) { + namespace = { value: Object.create(null) as Record, tokens: new Map() } + this.direct.set(descriptor.namespace, namespace) + Object.defineProperty(this, descriptor.namespace, { + configurable: true, + enumerable: true, + value: namespace.value, + }) + } + namespace.tokens.set(descriptor.method, token) + Object.defineProperty(namespace.value, descriptor.method, { + configurable: true, + enumerable: true, + value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), + }) + return () => { + if (namespace.tokens.get(descriptor.method) !== token) return + Reflect.deleteProperty(namespace.value, descriptor.method) + namespace.tokens.delete(descriptor.method) + if (namespace.tokens.size !== 0) return + this.direct.delete(descriptor.namespace) + Reflect.deleteProperty(this, descriptor.namespace) + } + } + + private installScoped( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + ): () => void { + let namespace = this.scoped.get(descriptor.namespace) + if (namespace === undefined) { + namespace = { + service: new ScopedRemoteNamespace( + this.ownerCtx, + descriptor.namespace, + (current, currentProjection, currentToken, caller, args) => + this.invoke(current, currentProjection, currentToken, caller, args), + ), + tokens: new Map(), + } + this.scoped.set(descriptor.namespace, namespace) + } + namespace.tokens.set(descriptor.method, token) + namespace.service.install(descriptor, projection, token) + return () => { + if (namespace.tokens.get(descriptor.method) !== token) return + namespace.service.remove(descriptor.method) + namespace.tokens.delete(descriptor.method) + } + } + + private async invoke( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + ): Promise { + const endpoint = endpointOf(descriptor) + if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) + const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) + if (values.length !== expected) { + throw new Error( + `client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`, + ) + } + const args: Record = {} + if (projection !== undefined) { + const binder = this.ownerCtx.typert.contexts.getClient(projection.context) + if (binder === undefined) { + throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) + } + const identity = binder.identity(callerCtx) + if (identity === undefined) { + throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) + } + args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire) + } + let valueIndex = 0 + descriptor.parameters.forEach((parameter, parameterIndex) => { + if (parameterIndex === projection?.parameterIndex) return + args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + valueIndex += 1 + }) + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) + const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal) + if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) + if (!result.ok) throw remoteFailure(endpoint, result.error) + return parse(descriptor.result, result.value, endpoint, 'result') + } +} + +type InvokeRemote = ( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + callerCtx: Context, + args: readonly unknown[], +) => Promise + +class ScopedRemoteNamespace extends Service { + private readonly ownerCtx: Context + private readonly methods = new Set() + + constructor( + ctx: Context, + name: string, + private readonly invokeRemote: InvokeRemote, + ) { + super(ctx, name) + this.ownerCtx = ctx + } + + assertMethodAvailable(method: string): void { + if (method in this) { + throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) + } + } + + install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + this.assertMethodAvailable(descriptor.method) + const method = descriptor.method + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { + return this.invokeRemote(descriptor, projection, token, this.ctx, args) + }, + }) + this.methods.add(method) + if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) { + this.ownerCtx.set(this.name, this) + } + } + + remove(method: string): void { + Reflect.deleteProperty(this, method) + this.methods.delete(method) + if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined) + } +} + +function endpointOf(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}` +} + +function mountActive(token: MountToken): boolean { + return token.active +} + +function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined { + if (descriptor.invocation.kind === 'context') { + return { + context: descriptor.invocation.context, + wire: descriptor.invocation.wire, + codec: descriptor.invocation.codec, + } + } + if (descriptor.scope === undefined) return undefined + const lookupParameters = descriptor.parameters + .map((parameter, index) => ({ parameter, index })) + .filter(candidate => candidate.parameter.source === 'lookup') + const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined + if (selected === undefined + || selected.parameter.wire !== descriptor.scope.wire + || selected.parameter.lookup !== descriptor.scope.context) { + throw new Error( + `client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`, + ) + } + return { + context: descriptor.scope.context, + wire: descriptor.scope.wire, + codec: selected.parameter.codec, + parameterIndex: selected.index, + } +} + +function requireStrictDescriptor(descriptor: InvocationDescriptor): void { + const endpoint = endpointOf(descriptor) + requireStrictCodec(descriptor.result, endpoint, 'result') + for (const parameter of descriptor.parameters) { + requireStrictCodec(parameter.codec, endpoint, parameter.wire) + } + if (descriptor.invocation.kind === 'context') { + requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire) + } +} + +function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void { + if (codec.mode !== 'strict') { + throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) + } +} + +function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown { + if (codec.mode !== 'strict') { + throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) + } + try { + return codec.schema.parse(value) + } catch (cause) { + throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause }) + } +} + +function remoteFailure(endpoint: string, error: RpcError): Error { + return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error }) +} diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts new file mode 100644 index 0000000000..ccb76e2d48 --- /dev/null +++ b/packages/host/api-gateway/src/index.ts @@ -0,0 +1,604 @@ +/** + * Live TypeRT Remote dispatch over Cordis Services and registered providers. + * Transport, request correlation, and response envelopes belong to Connection. + * @module @deepseek-ai/dsh-host-api-gateway + */ + +import { Context, Service, symbols } from 'cordis' +import { + remoteMethods, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type TypeRTCodec, + type TypeRTGatewayBinding, + type TypeRTLookupProvider, +} from '@deepseek-ai/dsh-type-meta' +import type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, +} from './types.ts' + +export type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, +} from './types.ts' + +interface GatewayErrorOptions { + readonly cause?: unknown + readonly field?: string +} + +interface ResolvedBinding { + readonly binding: TypeRTGatewayBinding + readonly original: object +} + +type ConnectionRpcResult = + | { readonly ok: true; readonly value: unknown } + | { + readonly ok: false + readonly error: { + readonly code: 'internal' + readonly message: string + readonly details: Record + } + } + +interface HostConnectionLike { + readonly rpc: { + handle( + channel: string, + handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, + options: { readonly authority: 'trusted-host' | 'loopback' }, + ): () => Promise + } +} + +/** Dispatch failure produced outside the invoked business method. */ +export class TypertGatewayError extends Error { + /** Machine-readable failure category. */ + readonly code: TypertGatewayErrorCode + /** Canonical `/` endpoint. */ + readonly endpoint: string + /** Affected wire field when the failure is field-specific. */ + readonly field: string | undefined + + /** + * Construct a Gateway failure without embedding boundary values in its message. + * @param code - stable failure category. + * @param endpoint - canonical Remote endpoint. + * @param message - correction-oriented diagnostic without sensitive values. + * @param options - optional field and contained cause. + */ + constructor( + code: TypertGatewayErrorCode, + endpoint: string, + message: string, + options: GatewayErrorOptions = {}, + ) { + super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause }) + this.name = 'TypertGatewayError' + this.code = code + this.endpoint = endpoint + this.field = options.field + } +} + +/** + * Resolve strict generated definitions or conservative SRC markers against + * current Cordis Services and TypeRT providers. + * @typert service typertGateway + */ +export class TypertGatewayService extends Service implements TypertGateway { + static inject = ['typert'] + + /** + * Register the Gateway against the active TypeRT registry. + * @param ctx - owning Host Context with TypeRT registry access. + */ + constructor(ctx: Context) { + super(ctx, 'typertGateway') + ctx.inject(['connection'], (connectionCtx) => { + const connection = connectionCtx.get('connection') as unknown as HostConnectionLike + connection.rpc.handle( + '/api2', + (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), + { authority: 'trusted-host' }, + ) + }) + } + + /** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + async invoke(request: InvokeRemoteRequest): Promise { + const endpoint = endpointOf(request.namespace, request.method) + const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) + assertExactArguments(request.args, descriptor, endpoint) + const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiver = receiverContext.get(descriptor.service) as unknown + if (!isObject(receiver)) { + throw new TypertGatewayError( + 'service-unavailable', + endpoint, + `active Service ${JSON.stringify(descriptor.service)} is unavailable`, + ) + } + validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) + const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + const implementation = descriptor.implementation ?? descriptor.method + const method = Reflect.get(receiver, implementation) as unknown + if (typeof method !== 'function') { + throw new TypertGatewayError( + 'method-unavailable', + endpoint, + `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, + ) + } + + const result = await Reflect.apply(method, receiver, args) as unknown + return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + _signal: AbortSignal, + ): Promise { + return this.invokeRpc(endpoint, payload) + } + + private async invokeRpc(endpoint: string, payload: unknown): Promise { + try { + const segments = endpoint.split('/') + const namespace = segments[0] + const method = segments[1] + if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) + } + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args)) { + throw new Error('Remote payload must contain exactly one plain-object args field') + } + const value = await this.invoke({ + namespace, + method, + args: payload.args, + }) + return { ok: true, value } + } catch (error) { + return rpcFailure(error) + } + } + + private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { + const strict = this.ctx.typert.local.get(endpoint) + if (strict !== undefined) return strict + if (this.ctx.typert.local.hasSeen(endpoint)) { + throw new TypertGatewayError( + 'definition-unavailable', + endpoint, + 'its strict definition was withdrawn and SRC fallback is forbidden', + ) + } + return this.resolveSrcDescriptor(namespace, method, endpoint) + } + + private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { + const candidates: InvocationDescriptor[] = [] + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== 'service') continue + const receiver = this.ctx.get(serviceKey) as unknown + if (!isObject(receiver)) continue + const original = originalOf(receiver) + const value = Reflect.get(original, 'typertGateway') as unknown + if (value === undefined) continue + const binding = readBinding(value, original, serviceKey, endpoint) + if (binding.namespace !== namespace) continue + const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method) + if (marker === undefined) continue + candidates.push(this.srcDescriptor(binding, marker, method, endpoint)) + } + if (candidates.length === 0) { + throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') + } + if (candidates.length > 1) { + throw new TypertGatewayError( + 'ambiguous-endpoint', + endpoint, + `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`, + ) + } + return candidates[0] as InvocationDescriptor + } + + private srcDescriptor( + binding: TypeRTGatewayBinding, + marker: ReturnType[number], + method: string, + endpoint: string, + ): InvocationDescriptor { + const names = methodParameterNames(binding.service, marker.method, endpoint) + const parameters: InvocationParameterDescriptor[] = [] + const wires = new Set() + for (const name of names) { + const matches = this.ctx.typert.lookups.keys() + .map(key => ({ key, provider: this.ctx.typert.lookups.get(key) })) + .filter((entry): entry is { key: string; provider: TypeRTLookupProvider } => + entry.provider?.parameter === name) + if (matches.length > 1) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `parameter ${JSON.stringify(name)} matches multiple lookup providers`, + { field: name }, + ) + } + const match = matches[0] + const parameter: InvocationParameterDescriptor = match === undefined + ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } } + : { + name, + wire: match.provider.wire, + source: 'lookup', + lookup: match.key, + codec: { mode: 'src-json' }, + } + if (wires.has(parameter.wire)) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, + { field: parameter.wire }, + ) + } + wires.add(parameter.wire) + parameters.push(parameter) + } + + let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' } + if (marker.invocation.kind === 'context') { + const provider = this.ctx.typert.contexts.getHost(marker.invocation.context) + if (provider === undefined) { + throw new TypertGatewayError( + 'context-unavailable', + endpoint, + `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, + ) + } + if (wires.has(provider.wire)) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, + { field: provider.wire }, + ) + } + receiver = { + kind: 'context', + context: marker.invocation.context, + wire: provider.wire, + codec: { mode: 'src-json' }, + } + } + + return { + id: `src:${binding.serviceKey}#${endpoint}`, + service: binding.serviceKey, + namespace: binding.namespace, + method, + ...(marker.method === method ? {} : { implementation: marker.method }), + invocation: receiver, + parameters, + result: { mode: 'src-json' }, + } + } + + private resolveReceiverContext( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, + ): Context { + if (descriptor.invocation.kind === 'direct') return this.ctx + const invocation = descriptor.invocation + const provider = this.ctx.typert.contexts.getHost(invocation.context) + if (provider === undefined) { + throw new TypertGatewayError( + 'context-unavailable', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} is unavailable`, + ) + } + if (provider.wire !== invocation.wire + || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) { + throw new TypertGatewayError( + 'provider-mismatch', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, + { field: invocation.wire }, + ) + } + const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) + let context: Context | undefined + try { + context = provider.resolve(identity) + } catch (cause) { + throw new TypertGatewayError( + 'context-failed', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} failed`, + { cause, field: invocation.wire }, + ) + } + if (context === undefined) { + throw new TypertGatewayError( + 'context-not-found', + endpoint, + `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, + { field: invocation.wire }, + ) + } + return context + } + + private resolveParameter( + parameter: InvocationParameterDescriptor, + args: Readonly>, + endpoint: string, + ): unknown { + const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) + if (parameter.source === 'json') return value + const key = parameter.lookup + if (key === undefined) { + throw new TypertGatewayError( + 'lookup-unavailable', + endpoint, + `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, + { field: parameter.wire }, + ) + } + const provider = this.ctx.typert.lookups.get(key) + if (provider === undefined) { + throw new TypertGatewayError( + 'lookup-unavailable', + endpoint, + `lookup provider ${JSON.stringify(key)} is unavailable`, + { field: parameter.wire }, + ) + } + if (provider.wire !== parameter.wire + || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) { + throw new TypertGatewayError( + 'provider-mismatch', + endpoint, + `lookup provider ${JSON.stringify(key)} does not match its strict definition`, + { field: parameter.wire }, + ) + } + let resolved: unknown + try { + resolved = provider.resolve(value) + } catch (cause) { + throw new TypertGatewayError( + 'lookup-failed', + endpoint, + `lookup provider ${JSON.stringify(key)} failed`, + { cause, field: parameter.wire }, + ) + } + if (resolved === undefined) { + throw new TypertGatewayError( + 'lookup-not-found', + endpoint, + `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, + { field: parameter.wire }, + ) + } + return resolved + } +} + +function rpcFailure(error: unknown): ConnectionRpcResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} + +function endpointOf(namespace: string, method: string): string { + return `${namespace}/${method}` +} + +function validateBinding( + receiver: object, + serviceKey: string, + namespace: string, + endpoint: string, +): ResolvedBinding { + const original = originalOf(receiver) + const value = Reflect.get(original, 'typertGateway') as unknown + if (value === undefined) { + throw new TypertGatewayError( + 'binding-invalid', + endpoint, + `Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`, + ) + } + return { + binding: readBinding(value, original, serviceKey, endpoint, namespace), + original, + } +} + +function readBinding( + value: unknown, + original: object, + serviceKey: string, + endpoint: string, + namespace?: string, +): TypeRTGatewayBinding { + if (!isObject(value) + || Reflect.get(value, 'service') !== original + || Reflect.get(value, 'serviceKey') !== serviceKey + || typeof Reflect.get(value, 'namespace') !== 'string' + || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) { + throw new TypertGatewayError( + 'binding-invalid', + endpoint, + `Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`, + ) + } + return value as unknown as TypeRTGatewayBinding +} + +function originalOf(receiver: object): object { + const original = Reflect.get(receiver, symbols.original) as unknown + return isObject(original) ? original : receiver +} + +function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] { + let prototype: object | null = Object.getPrototypeOf(service) as object | null + let implementation: ((this: object, ...args: never[]) => unknown) | undefined + while (prototype !== null) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, method) + if (descriptor !== undefined) { + if ('value' in descriptor && typeof descriptor.value === 'function') { + implementation = descriptor.value as (this: object, ...args: never[]) => unknown + } + break + } + prototype = Object.getPrototypeOf(prototype) as object | null + } + if (implementation === undefined) { + throw new TypertGatewayError( + 'method-unavailable', + endpoint, + `Remote marker has no prototype method ${JSON.stringify(method)}`, + ) + } + const source = Function.prototype.toString.call(implementation) + const open = source.indexOf('(') + const close = source.indexOf(')', open + 1) + if (open < 0 || close < 0) return invalidSignature(endpoint, method) + const body = source.slice(open + 1, close).trim() + if (body.length === 0) return [] + const parts = body.split(',').map(part => part.trim()) + if (parts.at(-1) === '') parts.pop() + const names = new Set() + for (const part of parts) { + if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) + names.add(part) + } + return [...names] +} + +function invalidSignature(endpoint: string, method: string): never { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, + ) +} + +function assertExactArguments( + args: Readonly>, + descriptor: InvocationDescriptor, + endpoint: string, +): void { + if (!isPlainObject(args)) { + throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object') + } + const expected = new Set(descriptor.parameters.map(parameter => parameter.wire)) + if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) + const actual = Reflect.ownKeys(args) + const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key)) + const missing = [...expected].filter(key => !Object.hasOwn(args, key)) + if (extra.length === 0 && missing.length === 0) return + const clauses: string[] = [] + if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) + if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`) + throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) +} + +function decode( + codec: TypeRTCodec, + value: unknown, + code: 'input-invalid' | 'result-invalid', + endpoint: string, + field: string, +): unknown { + try { + if (codec.mode === 'strict') return codec.schema.parse(value) + assertJsonValue(value, new Set()) + return value + } catch (cause) { + throw new TypertGatewayError( + code, + endpoint, + code === 'input-invalid' + ? `wire field ${JSON.stringify(field)} failed boundary validation` + : 'business result failed boundary validation', + { cause, field }, + ) + } +} + +function assertJsonValue(value: unknown, ancestors: Set): void { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return + if (typeof value === 'number') { + if (Number.isFinite(value)) return + throw new TypeError('non-finite number is not JSON-safe') + } + if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`) + if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe') + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) { + throw new TypeError('sparse or decorated array is not JSON-safe') + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe') + assertJsonValue(value[index], ancestors) + } + return + } + if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') + if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe') + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError('non-data property is not JSON-safe') + } + assertJsonValue(descriptor.value, ancestors) + } + } finally { + ancestors.delete(value) + } +} + +function isPlainObject(value: object): value is Record { + if (Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) as object | null + return prototype === null || prototype === Object.prototype +} + +function isObject(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function' +} + +export default TypertGatewayService diff --git a/packages/host/api-gateway/src/invariant.ts b/packages/host/api-gateway/src/invariant.ts new file mode 100644 index 0000000000..65c94b4ac4 --- /dev/null +++ b/packages/host/api-gateway/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-host-api-gateway`. + * @module @deepseek-ai/dsh-host-api-gateway/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-api-gateway' + +/** Cordis companion plugin name. */ +export const name = 'host-api-gateway-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: Host calls re-read authoritative Cordis and TypeRT + * state, while Client methods and descriptors mutate in one owned effect. + */ +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/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts new file mode 100644 index 0000000000..eea2bdc4f1 --- /dev/null +++ b/packages/host/api-gateway/src/types.ts @@ -0,0 +1,52 @@ +/** + * Carrier-independent TypeRT Gateway request, service, and error contracts. + * @module @deepseek-ai/dsh-host-api-gateway/types + */ + +/** One Remote method request after a carrier has decoded its envelope. */ +export interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} + +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +export type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' + +/** Host dispatcher consumed by Connection adapters. */ +export interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} + +declare module 'cordis' { + interface Context { + /** Host dispatcher for TypeRT Remote calls. */ + typertGateway: TypertGateway + } +} diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts new file mode 100644 index 0000000000..be0b12ed51 --- /dev/null +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -0,0 +1,222 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { + InvocationDescriptor, + TypeRTContext, + TypeRTRemoteContextApi, + TypeRTRemoteNamespace, +} from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { apply, inject } from '../src/client/index.ts' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + fixture: TypeRTContext + } + + interface TypeRTRemoteMap { + 'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }> + } + + interface TypeRTRemoteContextMap { + 'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> + } + + interface TypeRTRemoteNamespaceMap { + goals: TypeRTRemoteNamespace<'goals'> + } + +} + +type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'> + +const idSchema = z.string().min(1) +const requestSchema = z.object({ objective: z.string().min(1) }) +const createResultSchema = z.object({ ref: z.string().min(1) }) +const renameResultSchema = z.object({ renamed: z.boolean() }) + +function directDescriptor(): InvocationDescriptor { + return { + id: '@fixture/goals#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + scope: { context: 'fixture', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }, { + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, + } +} + +function contextDescriptor(): InvocationDescriptor { + return { + id: '@fixture/goals#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'fixture', + wire: 'agentId', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema }, + } +} + +async function bench(call: ConnectionHandle['rpc']['call']): Promise { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle) + await ctx.plugin({ inject, apply }) + return ctx +} + +describe('Client TypeRT API', () => { + it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + let retained: typeof ctx.api.goals.create | undefined + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + retained = scope.api.goals.create + }, + { inject: ['api'] }, + )) + await assembly + + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/create', + { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, + expect.any(AbortSignal), + ) + await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + + call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + + await assembly.dispose() + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals')).toBeUndefined() + expect(ctx.typert.remotes.list()).toEqual([]) + await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + }) + + it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-2' } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + }, + { inject: ['api'] }, + )) + await assembly + + await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/create', + { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, + expect.any(AbortSignal), + ) + await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('requires a "fixture" Context') + + await assembly.dispose() + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals')).toBeUndefined() + }) + + it('uses the caller Context identity for scoped namespace methods', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) + const assembly = ctx.plugin(Object.assign( + (scope: Context) => { + scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }) + }, + { inject: ['api'] }, + )) + await assembly + + await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + expect(call).toHaveBeenCalledWith( + '/api2', + 'goals/rename', + { args: { agentId: 'agent-2', request: { objective: 'land' } } }, + expect.any(AbortSignal), + ) + await expect((ctx as FixtureContext).goals.rename({ objective: 'land' })) + .rejects.toThrow('requires a "fixture" Context') + + await assembly.dispose() + expect(ctx.get('goals')).toBeUndefined() + }) + + it('rejects weak descriptors and namespace collisions before registration', async () => { + const ctx = await bench(vi.fn()) + const weak: InvocationDescriptor = { + ...directDescriptor(), + result: { mode: 'src-json' }, + } + + expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] })) + .toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/conflict', + descriptors: [{ ...directDescriptor(), namespace: 'mount' }], + })).toThrow('conflicts with the API service') + expect(ctx.typert.remotes.list()).toEqual([]) + }) + + it('throws RPC failures with the structured error as its cause', async () => { + const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } + const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) + ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + + let failure: unknown + try { + await ctx.api.goals.create('agent-1', { objective: 'ship' }) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail') + expect(failure.message).toContain('internal: host failed') + expect(failure.cause).toBe(rpcError) + }) +}) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts new file mode 100644 index 0000000000..8f7c144f5e --- /dev/null +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -0,0 +1,795 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { describe, expect, it } from 'vitest' +import { Context, Service, symbols } from 'cordis' +import { z } from 'zod' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { + bindTypeRTGateway, + Remote, + RemoteContext, + type InvocationDescriptor, + type TypeRTContext, + type TypeRTLookup, + type TypeRTLookupProvider, +} from '@deepseek-ai/dsh-type-meta' +import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway' + +interface FixtureAgent { + readonly id: string +} + +interface MarkedContext extends Context { + readonly fixtureScope?: string +} + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + gatewayFixture: TypeRTLookup + gatewayFixtureAlias: TypeRTLookup + } + + interface TypeRTContextMap { + gatewayFixture: TypeRTContext + } +} + +const emptyModel: TypertContribution['model'] = { + services: [], + events: [], + objects: [], +} + +class GoalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + readonly calls: string[] = [] + nextResult: unknown = undefined + businessError: Error | undefined + + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote + create(agent: FixtureAgent, request: { readonly title: string }): unknown { + this.calls.push('create') + return { + agentId: agent.id, + title: request.title, + scope: (this.ctx as MarkedContext).fixtureScope ?? 'root', + } + } + + @RemoteContext('gatewayFixture') + rename(request: { readonly title: string }): unknown { + this.calls.push('rename') + return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' } + } + + @Remote + passthrough(value: unknown): unknown { + this.calls.push('passthrough') + return this.nextResult === undefined ? value : this.nextResult + } + + @Remote + fail(request: unknown): never { + void request + this.calls.push('fail') + throw this.businessError ?? new Error('fixture business failure') + } + + strictOnly(request: { readonly title: string }): unknown { + this.calls.push('strictOnly') + return this.nextResult === undefined ? request : this.nextResult + } +} + +type FakeRpcResult = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } } + +type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise + +class FakeConnectionService extends Service { + channel: string | undefined + authority: string | undefined + handler: FakeRpcHandler | undefined + + constructor(ctx: Context) { + super(ctx, 'connection') + } + + get rpc() { + const owner = this.ctx + return { + handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) => + owner.effect(() => { + this.channel = channel + this.authority = options.authority + this.handler = handler + return () => { + this.channel = undefined + this.authority = undefined + this.handler = undefined + } + }), + } + } +} + +function fakeHttpServer(routes: WebRoute[]): Pick { + return { + register(route) { + if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) { + throw new Error(`duplicate route ${route.path}`) + } + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } +} + +async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; close(): Promise }> { + const server = createServer((request, response) => { + void route.handler(request, response) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + return { + origin: `http://127.0.0.1:${String(address.port)}`, + close: () => new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined || error === null) resolve() + else reject(error) + }) + }), + } +} + +class FirstSharedService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'firstShared', { namespace: 'shared' }) + + constructor(ctx: Context) { + super(ctx, 'firstShared') + } + + @Remote + run(value: string): string { + return value + } +} + +class SecondSharedService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'secondShared', { namespace: 'shared' }) + + constructor(ctx: Context) { + super(ctx, 'secondShared') + } + + @Remote + run(value: string): string { + return value + } +} + +class DefaultParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'defaultParameter', { namespace: 'invalid-default' }) + + constructor(ctx: Context) { + super(ctx, 'defaultParameter') + } + + @Remote + run(value = 'fallback'): string { + return value + } +} + +class DestructuredParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'destructuredParameter', { namespace: 'invalid-destructure' }) + + constructor(ctx: Context) { + super(ctx, 'destructuredParameter') + } + + @Remote + run({ value }: { readonly value: string }): string { + return value + } +} + +class RestParameterService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'restParameter', { namespace: 'invalid-rest' }) + + constructor(ctx: Context) { + super(ctx, 'restParameter') + } + + @Remote + run(...values: readonly unknown[]): string { + return values.map(String).join(',') + } +} + +class WrongBindingService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' }) + + constructor(ctx: Context) { + super(ctx, 'wrongBinding') + } + + @Remote + run(value: string): string { + return value + } +} + +describe('TypertGatewayService', () => { + it('invokes a strict direct method with schema decoding and a live lookup', async () => { + const { ctx, service } = await setup() + const agent = { id: 'agent-1' } + registerAgentLookup(ctx, agent) + registerStrict(ctx, [createDescriptor()]) + const caller = ctx.extend({ fixtureScope: 'direct-caller' }) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: ' ship ' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' }) + expect(service.calls).toEqual(['create']) + }) + + it('resolves strict Remote Context identity without adding a business argument', async () => { + const { ctx, service } = await setup() + const scoped = ctx.extend({ fixtureScope: 'agent-scope' }) + ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + registerStrict(ctx, [renameDescriptor()]) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).resolves.toEqual({ title: 'land', scope: 'agent-scope' }) + expect(service.calls).toEqual(['rename']) + }) + + it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => { + const { ctx } = await setup() + const agent = { id: 'agent-1' } + registerAgentLookup(ctx, agent) + const caller = ctx.extend({ fixtureScope: 'direct-src' }) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) + }) + + it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { + const { ctx } = await setup() + const scoped = ctx.extend({ fixtureScope: 'agent-src' }) + ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).resolves.toEqual({ title: 'land', scope: 'agent-src' }) + }) + + it('re-reads Service and providers on every strict invocation', async () => { + const { ctx, serviceFiber } = await setup() + const agent = { id: 'agent-1' } + const disposeLookup = registerAgentLookup(ctx, agent) + registerStrict(ctx, [createDescriptor()]) + + await disposeLookup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-unavailable') + + registerAgentLookup(ctx, agent) + await serviceFiber.dispose() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'service-unavailable') + }) + + it('re-reads and contains Context providers', async () => { + const { ctx } = await setup() + const scoped = ctx.extend() + const dispose = ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) + registerStrict(ctx, [renameDescriptor()]) + + await dispose() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-unavailable') + + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + resolve: () => { throw new Error('provider failed') }, + }) + const error = await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-failed') + expect(error.cause).toEqual(new Error('provider failed')) + }) + + it('never downgrades an observed strict endpoint after definition disposal', async () => { + const { ctx } = await setup() + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await ctx.plugin(TypertGatewayService) + await ctx.plugin(GoalService) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('retains the no-downgrade guard across Gateway Service reloads', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + await ctx.plugin(GoalService) + const dispose = registerStrict(ctx, [passthroughDescriptor()]) + await dispose() + + await gatewayFiber.dispose() + await ctx.plugin(TypertGatewayService) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: 'would pass through SRC' }, + }), 'definition-unavailable') + }) + + it('rejects ambiguous SRC endpoints independently of reflection order', async () => { + const ctx = await setupGateway() + await ctx.plugin(FirstSharedService) + await ctx.plugin(SecondSharedService) + + const error = await expectCode(ctx.typertGateway.invoke({ + namespace: 'shared', + method: 'run', + args: { value: 'ship' }, + }), 'ambiguous-endpoint') + expect(error.message).toContain('firstShared, secondShared') + }) + + it('rejects SRC signatures that cannot map one wire field to each position', async () => { + const cases = [ + { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } }, + { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } }, + { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } }, + ] as const + for (const testCase of cases) { + const ctx = await setupGateway() + await ctx.plugin(testCase.plugin) + await expectCode(ctx.typertGateway.invoke({ + namespace: testCase.namespace, + method: 'run', + args: testCase.args, + }), 'signature-invalid') + } + }) + + it('rejects a SRC parameter matching more than one lookup provider', async () => { + const { ctx } = await setup() + const provider = agentLookup({ id: 'agent-1' }) + ctx.typert.lookups.register('gatewayFixture', provider) + ctx.typert.lookups.register('gatewayFixtureAlias', provider) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'signature-invalid') + }) + + it('requires exact wire fields before invoking business code', async () => { + const { ctx, service } = await setup() + registerAgentLookup(ctx, { id: 'agent-1' }) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { request: { title: 'ship' } }, + }), 'arguments-invalid') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, + }), 'arguments-invalid') + expect(service.calls).toEqual([]) + }) + + it('distinguishes strict input and result validation failures', async () => { + const { ctx, service } = await setup() + registerStrict(ctx, [strictOnlyDescriptor()]) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 1 } }, + }), 'input-invalid') + + service.nextResult = { title: 1 } + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 'ship' } }, + }), 'result-invalid') + }) + + it.each([ + undefined, + Number.NaN, + Number.POSITIVE_INFINITY, + 1n, + Symbol('value'), + () => 'value', + new Date(0), + new Map(), + [, 'sparse'], + ])('rejects non-JSON SRC input %#', async (value) => { + const { ctx } = await setup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value }, + }), 'input-invalid') + }) + + it('rejects cyclic SRC input and non-JSON SRC results', async () => { + const { ctx, service } = await setup() + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: cyclic }, + }), 'input-invalid') + + service.nextResult = new Date(0) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: null }, + }), 'result-invalid') + }) + + it('validates strict provider identity against generated wire metadata', async () => { + const { ctx } = await setup() + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + wire: 'differentAgentId', + }) + registerStrict(ctx, [createDescriptor()]) + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'provider-mismatch') + }) + + it('validates binding identity and active method availability', async () => { + const ctx = await setupGateway() + await ctx.plugin(WrongBindingService) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'wrong-binding', + method: 'run', + args: { value: 'ship' }, + }), 'binding-invalid') + + await ctx.plugin(GoalService) + registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'missing', + args: { value: 'ship' }, + }), 'method-unavailable') + }) + + it('preserves business exception identity after invocation begins', async () => { + const { ctx, service } = await setup() + const failure = new Error('business identity') + service.businessError = failure + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'fail', + args: { request: { reason: 'fixture' } }, + })).rejects.toBe(failure) + }) + + it('reports an absent endpoint without retaining receiver state', async () => { + const { ctx } = await setup() + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'absent', + args: {}, + }), 'invocation-unavailable') + }) + + it('mounts /api2 through an optional Connection and returns existing RPC results', async () => { + const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + await ctx.plugin(GoalService) + const connection = rawConnection(ctx) + expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' }) + + registerAgentLookup(ctx, { id: 'agent-1' }) + registerStrict(ctx, [createDescriptor()]) + const signal = new AbortController().signal + const handler = connection.handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler') + await expect(handler('goals/create', { + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }, signal)).resolves.toEqual({ + ok: true, + value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, + }) + const invalid = await handler('goals/create', { invalid: true }, signal) + expect(invalid).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') + expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + + await gatewayFiber.dispose() + expect(connection.handler).toBeUndefined() + }) + + it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => { + const ctx = new Context().extend({ fixtureScope: 'http-caller' }) + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + await connectionFiber + await ctx.plugin(TypertRegistry) + const gatewayFiber = ctx.plugin(TypertGatewayService) + await gatewayFiber + const goalFiber = ctx.plugin(GoalService) + await goalFiber + const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' }) + const removeStrict = registerStrict(ctx, [createDescriptor()]) + expect(routes).toHaveLength(1) + const server = await serveRoute(routes[0]!) + + try { + const response = await fetch(`${server.origin}/api2/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-http', + method: 'goals/create', + payload: { args: { agentId: 'agent-1', request: { title: ' ship ' } } }, + }), + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + type: 'server-response', + rpcId: 'rpc-http', + result: { + ok: true, + value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' }, + }, + }) + } finally { + await server.close() + await removeStrict() + await removeLookup() + await goalFiber.dispose() + await gatewayFiber.dispose() + await connectionFiber.dispose() + } + expect(routes).toHaveLength(0) + }) +}) + +async function setup(): Promise<{ + readonly ctx: Context + readonly service: GoalService + readonly serviceFiber: ReturnType +}> { + const ctx = await setupGateway() + const serviceFiber = ctx.plugin(GoalService) + await serviceFiber + return { ctx, service: rawGoalService(ctx), serviceFiber } +} + +async function setupGateway(): Promise { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService) + return ctx +} + +function rawGoalService(ctx: Context): GoalService { + const receiver = ctx.get('goals') as unknown as GoalService & { [symbols.original]?: GoalService } + return receiver[symbols.original] ?? receiver +} + +function rawConnection(ctx: Context): FakeConnectionService { + const receiver = ctx.get('connection') as unknown as FakeConnectionService & { + [symbols.original]?: FakeConnectionService + } + return receiver[symbols.original] ?? receiver +} + +function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { + return ctx.typert.register({ + package: '@fixture/gateway', + face: 'host', + schemas: [], + model: emptyModel, + invocations: descriptors, + }) +} + +function registerAgentLookup(ctx: Context, agent: FixtureAgent): () => Promise { + return ctx.typert.lookups.register('gatewayFixture', agentLookup(agent)) +} + +function agentLookup(agent: FixtureAgent): TypeRTLookupProvider { + return { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/domain#Agent', + wireTypeSymbol: '@fixture/domain#AgentId', + resolve: id => id === agent.id ? agent : undefined, + } +} + +function contextProvider(context: Context) { + return { + wire: 'agentId', + wireTypeSymbol: '@fixture/domain#AgentId', + resolve: (id: string) => id === 'agent-1' ? context : undefined, + } +} + +function strictCodec(typeSymbol: string, schema: z.ZodType): InvocationDescriptor['result'] { + return { mode: 'strict', typeSymbol, schema } +} + +function createDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'gatewayFixture', + codec: strictCodec('@fixture/domain#AgentId', z.string()), + }, + { + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('@fixture/gateway#CreateRequest', z.object({ + title: z.string().transform(value => value.trim()), + })), + }, + ], + result: strictCodec('@fixture/gateway#CreateResult', z.object({ + agentId: z.string(), + title: z.string(), + scope: z.string(), + })), + } +} + +function renameDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'gatewayFixture', + wire: 'agentId', + codec: strictCodec('@fixture/domain#AgentId', z.string()), + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('@fixture/gateway#RenameRequest', z.object({ title: z.string() })), + }], + result: strictCodec('@fixture/gateway#RenameResult', z.object({ + title: z.string(), + scope: z.string(), + })), + } +} + +function passthroughDescriptor(): InvocationDescriptor { + return { + id: '@fixture/gateway#goals/passthrough', + service: 'goals', + namespace: 'goals', + method: 'passthrough', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'value', + wire: 'value', + source: 'json', + codec: { mode: 'src-json' }, + }], + result: { mode: 'src-json' }, + } +} + +function strictOnlyDescriptor(): InvocationDescriptor { + const value = strictCodec('@fixture/gateway#StrictValue', z.object({ title: z.string() })) + return { + id: '@fixture/gateway#goals/strictOnly', + service: 'goals', + namespace: 'goals', + method: 'strictOnly', + invocation: { kind: 'direct' }, + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: value }], + result: value, + } +} + +async function expectCode( + promise: Promise, + code: TypertGatewayError['code'], +): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(TypertGatewayError) + expect(error).toMatchObject({ code }) + return error as TypertGatewayError + } + throw new Error(`expected TypertGatewayError ${code}`) +} diff --git a/packages/host/api-gateway/tsconfig.json b/packages/host/api-gateway/tsconfig.json new file mode 100644 index 0000000000..fea39663f7 --- /dev/null +++ b/packages/host/api-gateway/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../client/connection" + }, + { + "path": "../../typert/type-meta" + } + ] +} diff --git a/packages/host/api-gateway/tsdown.config.ts b/packages/host/api-gateway/tsdown.config.ts new file mode 100644 index 0000000000..1f95a1f2c5 --- /dev/null +++ b/packages/host/api-gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-host-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c9dea52f98..cb83c5328d 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -72,6 +72,11 @@ export type { // ---- Errors and ids ---- export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' +export { + clientRequestSchema, + serverRequestSchema, + serverResponseSchema, +} from './rpc.schema.ts' // ---- Fixed session-search product bounds ---- export { diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 3e9d8f7f61..5ffb933214 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,6 +30,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 005b8e2157..5757d7cef5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -15,6 +15,8 @@ import type { EnumMemberModel, ExportModel, FaceModel, + InvocationModel, + InvocationParameterModel, JsDocTagModel, KeywordTypeName, MemberBase, @@ -23,6 +25,8 @@ import type { ObjectModel, PackageModel, ParameterModel, + RemoteBoundaryModel, + RemoteTypeImportModel, SchemaModel, ServiceModel, SignatureModel, @@ -122,6 +126,25 @@ interface ModuleIdentity { readonly subpath: string } +interface StaticLookupDeclaration { + readonly key: string + readonly hostSymbol: SymbolId + readonly wireType: ts.TypeNode + readonly site: ts.Node +} + +interface StaticContextDeclaration { + readonly key: string + readonly wireType: ts.TypeNode + readonly site: ts.Node +} + +interface GatewayBinding { + readonly service: string + readonly namespace: string + readonly site: ts.PropertyDeclaration +} + type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] } @@ -453,15 +476,11 @@ export class WorkspaceAnalyzer { config: this.caches.config(configPath), manifest, } - const packagePath = slash(relative(this.options.root, packageRoot)) - const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/') - if (clientPackage && isDualFacePackage(manifest)) { + if (isDualFacePackage(manifest)) { registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }) registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }) - } else if (clientPackage) { - registrations.push({ ...registration, face: 'client' }) } else { - registrations.push({ ...registration, face: 'host' }) + registrations.push(registration) } } } @@ -480,6 +499,7 @@ export class WorkspaceAnalyzer { && subpath !== './package.json' && subpath !== './typert' && subpath !== './client/typert' + && subpath !== './remote' && !target.endsWith('.json')) .map(([, target]) => sourcePathForExport(registration.root, target)) .filter(existsSync) @@ -578,6 +598,8 @@ class FaceAnalyzer { private readonly nodes = new Map() private readonly exportsByPackage = new Map() private readonly nodeOrdinals = new Map() + private staticLookups: readonly StaticLookupDeclaration[] | undefined + private staticContexts: ReadonlyMap | undefined constructor(options: FaceAnalyzerOptions) { this.root = options.root @@ -601,6 +623,7 @@ class FaceAnalyzer { const packages = this.registrations .map(registration => this.analyzePackage(registration)) .filter(hasPackageSurface) + this.validateInvocationIdentity(packages) return { face: this.face, packages, @@ -634,6 +657,7 @@ class FaceAnalyzer { } } } + const explicitServices = this.collectExplicitServices(records) const objects: ObjectModel[] = [] const schemas: SchemaModel[] = [] @@ -672,10 +696,14 @@ class FaceAnalyzer { root: slash(relative(this.root, registration.root)), exports: records.map(record => record.model) .sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)), - services: uniqueBy(services, service => service.key).sort((left, right) => left.key.localeCompare(right.key)), + services: uniqueBy([...explicitServices, ...services], service => service.key) + .sort((left, right) => left.key.localeCompare(right.key)), events: uniqueBy(events, event => event.name).sort((left, right) => left.name.localeCompare(right.name)), objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)), schemas: schemas.sort((left, right) => left.export.name.localeCompare(right.export.name)), + invocations: this.face === 'host' + ? this.collectInvocations(registration, reachable).sort((left, right) => left.id.localeCompare(right.id)) + : [], } } @@ -686,7 +714,7 @@ class FaceAnalyzer { const records: ExportRecord[] = [] for (const [subpath, target] of targets) { if (target.includes('*') || subpath === './package.json' - || subpath === './typert' || subpath === './client/typert' + || subpath === './typert' || subpath === './client/typert' || subpath === './remote' // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API. || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue const sourcePath = sourcePathForExport(registration.root, target) @@ -849,6 +877,740 @@ class FaceAnalyzer { return result } + private collectExplicitServices(records: readonly ExportRecord[]): ServiceModel[] { + const result: ServiceModel[] = [] + const seen = new Set() + for (const record of records) { + const tag = typertServiceTag(record.declaration) + if (tag === undefined) continue + const words = (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/) + if (words.length !== 2 || !isRemoteSegment(words[1] ?? '')) { + this.fail(tag, '@typert service requires exactly one nonempty Cordis service key without "/"') + } + if (!ts.isClassDeclaration(record.declaration)) { + this.fail(record.declaration, '@typert service requires an exported class') + } + const symbol = this.resolveSymbol(record.symbol) + const symbolId = this.symbolId(symbol) + if (seen.has(symbolId)) continue + seen.add(symbolId) + const model = this.ensureDeclaration(symbol, record.declaration) + result.push({ + ...documentationOf(record.declaration), + key: words[1] as string, + symbol: symbolId, + export: record.model, + members: model.members.filter(exposableMember).map(member => member.id), + location: this.location(record.declaration), + }) + } + return result + } + + private collectInvocations( + registration: PackageRegistration, + reachable: readonly ts.SourceFile[], + ): InvocationModel[] { + const result: InvocationModel[] = [] + for (const sourceFile of reachable) { + for (const statement of sourceFile.statements) { + if (!ts.isClassDeclaration(statement)) continue + const marked = statement.members.flatMap((member) => { + const invocation = this.remoteMarker(member) + if (invocation === undefined) return [] + if (!ts.isMethodDeclaration(member)) { + this.fail(member, 'Remote decorators require a public instance method') + } + return [{ method: member, invocation }] + }) + const first = marked[0] + if (first === undefined) continue + const binding = this.gatewayBinding(statement) + if (binding === undefined) { + this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)') + } + for (const { method, invocation } of marked) { + result.push(this.invocationModel(registration, binding, method, invocation)) + } + } + } + return result + } + + private invocationModel( + registration: PackageRegistration, + binding: GatewayBinding, + method: ts.MethodDeclaration, + invocation: + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }, + ): InvocationModel { + if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) { + this.fail(method, 'Remote decorators require a public instance method') + } + if (hasModifier(method, ts.SyntaxKind.AbstractKeyword) || method.body === undefined) { + this.fail(method, 'Remote methods must have a concrete implementation') + } + if (!ts.isIdentifier(method.name)) { + this.fail(method, 'Remote method names must be identifiers') + } + if ((method.typeParameters?.length ?? 0) > 0) { + this.fail(method, 'generic Remote methods are not supported') + } + const methodName = method.name.text + const exportedMethod = invocation.exportName ?? methodName + + const lookups = this.lookupDeclarations() + const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup])) + const parameters: InvocationParameterModel[] = [] + const wires = new Set() + for (const parameter of method.parameters) { + if (!ts.isIdentifier(parameter.name)) { + this.fail(parameter, 'Remote parameters must use identifier bindings') + } + if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters') + if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values') + if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') + if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') + const authoredType = this.requiredType(parameter, parameter.type, 'parameter') + const hostSymbol = this.symbolAtType(authoredType) + const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) + let modeled: InvocationParameterModel + if (lookup !== undefined) { + if (parameter.name.text !== lookup.key) { + this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`) + } + const boundary = this.remoteBoundary( + lookup.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${lookup.key}Id`, + true, + ) + modeled = { + name: parameter.name.text, + wire: `${lookup.key}Id`, + source: 'lookup', + lookup: lookup.key, + boundary, + } + } else { + if (hostSymbol !== undefined && this.isWorkspaceClass(hostSymbol)) { + this.fail(parameter, `non-JSON class parameter ${hostSymbol.name} requires a TypeRTLookupMap entry`) + } + modeled = { + name: parameter.name.text, + wire: parameter.name.text, + source: 'json', + boundary: this.remoteBoundary( + authoredType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`, + false, + ), + } + } + if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`) + wires.add(modeled.wire) + parameters.push(modeled) + } + + let receiver: InvocationModel['invocation'] = { kind: 'direct' } + if (invocation.kind === 'context') { + const context = this.contextDeclarations().get(invocation.context) + if (context === undefined) { + this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`) + } + const wire = `${invocation.context}Id` + if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`) + receiver = { + kind: 'context', + context: invocation.context, + wire, + boundary: this.remoteBoundary( + context.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:${wire}`, + true, + ), + } + } + + let scope: InvocationModel['scope'] + if (invocation.kind === 'direct') { + const lookupParameters = parameters.filter(parameter => parameter.source === 'lookup') + const parameter = lookupParameters.length === 1 ? lookupParameters[0] : undefined + const context = parameter?.lookup === undefined + ? undefined + : this.contextDeclarations().get(parameter.lookup) + if (parameter !== undefined && context !== undefined) { + const contextBoundary = this.remoteBoundary( + context.wireType, + `${registration.name}#${binding.namespace}/${exportedMethod}:scope:${context.key}`, + true, + ) + if (contextBoundary.typeSymbol !== parameter.boundary.typeSymbol) { + this.fail( + method, + `Remote scope ${context.key} wire type ${contextBoundary.typeSymbol} does not match lookup wire type ${parameter.boundary.typeSymbol}`, + ) + } + scope = { context: context.key, wire: parameter.wire } + } + } + + const resultType = this.remoteResultType(method) + return { + id: `${registration.name}#${binding.namespace}/${exportedMethod}`, + service: binding.service, + namespace: binding.namespace, + method: exportedMethod, + ...(exportedMethod === methodName ? {} : { implementation: methodName }), + invocation: receiver, + ...(scope === undefined ? {} : { scope }), + parameters, + result: this.remoteBoundary( + resultType, + `${registration.name}#${binding.namespace}/${exportedMethod}:result`, + false, + ), + location: this.location(method.name), + } + } + + private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration => + ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway') + const [property, duplicate] = candidates + if (property === undefined) return undefined + if (duplicate !== undefined) this.fail(duplicate, 'Service has more than one typertGateway field') + if (visibilityOf(property) !== 'public' + || hasModifier(property, ts.SyntaxKind.StaticKeyword) + || !hasModifier(property, ts.SyntaxKind.ReadonlyKeyword)) { + this.fail(property, 'typertGateway must be a public readonly instance field') + } + if (property.initializer === undefined + || !ts.isCallExpression(property.initializer) + || !this.isTypeMetaSymbol(property.initializer.expression, 'bindTypeRTGateway')) { + this.fail(property, 'typertGateway must call bindTypeRTGateway()') + } + const call = property.initializer + if (call.arguments.length < 2 || call.arguments.length > 3) { + this.fail(call, 'bindTypeRTGateway() requires this, service key, and an optional options object') + } + if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) { + this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this') + } + const serviceArgument = call.arguments[1] + if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal') + const service = stringLiteralValue(serviceArgument) + if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal') + let namespace = service + const options = call.arguments[2] + if (options !== undefined) { + if (!ts.isObjectLiteralExpression(options)) { + this.fail(options, 'bindTypeRTGateway() options must be an object literal') + } + for (const propertyOption of options.properties) { + if (!ts.isPropertyAssignment(propertyOption) + || memberName(propertyOption.name) !== 'namespace') { + this.fail(propertyOption, 'bindTypeRTGateway() only supports a namespace option') + } + const value = stringLiteralValue(propertyOption.initializer) + if (value === undefined) this.fail(propertyOption.initializer, 'Gateway namespace must be a string literal') + namespace = value + } + } + if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') + if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') + return { service, namespace, site: property } + } + + private remoteMarker( + member: ts.ClassElement, + ): + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } + | undefined { + let found: + | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } + | undefined + for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { + const expression = decorator.expression + let marker: typeof found + if (this.isTypeMetaSymbol(expression, 'Remote')) { + marker = { kind: 'direct' } + } else if (ts.isCallExpression(expression) + && this.isTypeMetaSymbol(expression.expression, 'Remote')) { + if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') + const exportName = stringLiteralValue(expression.arguments[0]) + if (exportName === undefined || !isRemoteSegment(exportName)) { + this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"') + } + marker = { kind: 'direct', exportName } + } else if (ts.isCallExpression(expression) + && this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) { + if (expression.arguments.length < 1 || expression.arguments.length > 2) { + this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name') + } + const context = stringLiteralValue(expression.arguments[0]) + if (context === undefined || !isRemoteSegment(context)) { + this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"') + } + const exportArgument = expression.arguments[1] + const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) + if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { + this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"') + } + marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } + } else { + continue + } + if (found !== undefined) this.fail(decorator, 'a method can have only one Remote invocation decorator') + found = marker + } + return found + } + + private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode { + const authored = this.requiredType(method, method.type, 'return') + if (!ts.isTypeReferenceNode(authored)) return authored + const symbol = this.checker.getSymbolAtLocation(authored.typeName) + const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) + const resultType = authored.typeArguments?.[0] + if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored + const declaration = preferredDeclaration(resolved) + if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored + return resultType + } + + private lookupDeclarations(): readonly StaticLookupDeclaration[] { + if (this.staticLookups !== undefined) return this.staticLookups + const byKey = new Map() + const byHost = new Map() + for (const declaration of this.typeMetaMapMembers('TypeRTLookupMap')) { + if (!ts.isPropertySignature(declaration) || declaration.type === undefined) { + this.fail(declaration, 'TypeRTLookupMap entries must be required properties') + } + const key = memberName(declaration.name) + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"') + if (!ts.isTypeReferenceNode(declaration.type) + || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup') + || declaration.type.typeArguments?.length !== 2) { + this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup') + } + const hostType = declaration.type.typeArguments[0] + const wireType = declaration.type.typeArguments[1] + if (hostType === undefined || wireType === undefined) { + this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup') + } + const host = this.symbolAtType(hostType) + if (host === undefined) this.fail(hostType, 'TypeRTLookup Host must be a named type') + const entry: StaticLookupDeclaration = { + key, + hostSymbol: this.symbolId(host), + wireType, + site: declaration, + } + if (byKey.has(key)) this.fail(declaration, `duplicate TypeRTLookupMap key ${key}`) + if (byHost.has(entry.hostSymbol)) this.fail(declaration, `Host type ${host.name} has more than one TypeRT lookup`) + byKey.set(key, entry) + byHost.set(entry.hostSymbol, entry) + } + this.staticLookups = [...byKey.values()] + return this.staticLookups + } + + private contextDeclarations(): ReadonlyMap { + if (this.staticContexts !== undefined) return this.staticContexts + const result = new Map() + for (const declaration of this.typeMetaMapMembers('TypeRTContextMap')) { + if (!ts.isPropertySignature(declaration) || declaration.type === undefined) { + this.fail(declaration, 'TypeRTContextMap entries must be required properties') + } + const key = memberName(declaration.name) + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"') + if (!ts.isTypeReferenceNode(declaration.type) + || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext') + || declaration.type.typeArguments?.length !== 1) { + this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext') + } + if (result.has(key)) this.fail(declaration, `duplicate TypeRTContextMap key ${key}`) + const wireType = declaration.type.typeArguments[0] + if (wireType === undefined) this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext') + result.set(key, { + key, + wireType, + site: declaration, + }) + } + this.staticContexts = result + return result + } + + private typeMetaMapMembers(name: 'TypeRTLookupMap' | 'TypeRTContextMap'): ts.TypeElement[] { + const result: ts.TypeElement[] = [] + for (const sourceFile of this.program.getSourceFiles()) { + for (const statement of sourceFile.statements) { + if (!ts.isModuleDeclaration(statement) + || !ts.isStringLiteral(statement.name) + || statement.name.text !== '@deepseek-ai/dsh-type-meta' + || statement.body === undefined + || !ts.isModuleBlock(statement.body)) continue + for (const nested of statement.body.statements) { + if (ts.isInterfaceDeclaration(nested) && nested.name.text === name) result.push(...nested.members) + } + } + } + return result + } + + private remoteBoundary( + authoredType: ts.TypeNode, + fallbackTypeSymbol: string, + requireNamed: boolean, + ): RemoteBoundaryModel { + const type = this.convertType(authoredType) + const codecType = this.resolvedRemoteCodecType(authoredType) + const rootSymbol = this.namedWorkspaceType(authoredType) + if (rootSymbol !== undefined) { + const imported = this.publicRemoteType(rootSymbol, authoredType) + return { + type, + codecType, + typeSymbol: `${imported.specifier}#${imported.name}`, + imports: [imported], + } + } + if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') + const imports = new Map() + const visit = (node: ts.Node): void => { + if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) { + const symbol = ts.isTypeReferenceNode(node) + ? this.checker.getSymbolAtLocation(node.typeName) + : node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier) + if (symbol !== undefined) { + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration !== undefined + && !isStandardLibraryFile(declaration.getSourceFile().fileName) + && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) { + const imported = this.publicRemoteType(resolved, node) + imports.set(imported.symbol, imported) + return + } + } + } + ts.forEachChild(node, visit) + } + visit(authoredType) + return { + type, + codecType, + typeSymbol: fallbackTypeSymbol, + imports: [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), + } + } + + /** + * Project one authored Remote boundary through the complete face Program. + * Consumer declarations retain the authored alias, while codecs use this + * concrete graph so declaration-merged mapped and conditional types are + * validated without teaching the compiler-independent emitter TypeScript's + * type evaluator. + */ + private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { + const completed = new Map() + const active = new Map() + const recursiveDeclarations = new Map() + const convert = (type: ts.Type): TypeNodeId => { + const cached = completed.get(type) + if (cached !== undefined) return cached + const activeId = active.get(type) + if (activeId !== undefined) { + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + const elementId = element === undefined ? undefined : active.get(element) + if (element !== undefined && elementId !== undefined) { + return this.addNode(authoredType, { + kind: 'array', + element: this.resolvedCycleReference( + element, + authoredType, + elementId, + recursiveDeclarations, + ), + }) + } + } + return this.resolvedCycleReference(type, authoredType, activeId, recursiveDeclarations) + } + const id = this.allocateNodeId(authoredType) + active.set(type, id) + try { + const add = (model: TypeNodeInput): TypeNodeId => { + this.nodes.set(id, { id, ...model }) + completed.set(type, id) + return id + } + const flags = type.flags + if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' }) + if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' }) + if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' }) + if ((flags & ts.TypeFlags.String) !== 0) return add({ kind: 'keyword', name: 'string' }) + if ((flags & ts.TypeFlags.Number) !== 0) return add({ kind: 'keyword', name: 'number' }) + if ((flags & ts.TypeFlags.BigInt) !== 0) return add({ kind: 'keyword', name: 'bigint' }) + if ((flags & ts.TypeFlags.Boolean) !== 0) return add({ kind: 'keyword', name: 'boolean' }) + if ((flags & ts.TypeFlags.ESSymbol) !== 0) return add({ kind: 'keyword', name: 'symbol' }) + if ((flags & ts.TypeFlags.Undefined) !== 0) return add({ kind: 'keyword', name: 'undefined' }) + if ((flags & ts.TypeFlags.Void) !== 0) return add({ kind: 'keyword', name: 'void' }) + if ((flags & ts.TypeFlags.Null) !== 0) return add({ kind: 'literal', value: null, text: 'null' }) + if ((flags & ts.TypeFlags.StringLiteral) !== 0) { + const value = (type as ts.StringLiteralType).value + return add({ kind: 'literal', value, text: JSON.stringify(value) }) + } + if ((flags & ts.TypeFlags.NumberLiteral) !== 0) { + const value = (type as ts.NumberLiteralType).value + return add({ kind: 'literal', value, text: String(value) }) + } + if ((flags & ts.TypeFlags.BigIntLiteral) !== 0) { + const value = (type as ts.BigIntLiteralType).value + const text = `${value.negative ? '-' : ''}${value.base10Value}n` + return add({ kind: 'literal', value: BigInt(`${value.negative ? '-' : ''}${value.base10Value}`), text }) + } + if ((flags & ts.TypeFlags.BooleanLiteral) !== 0) { + const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true' + return add({ kind: 'literal', value, text: String(value) }) + } + if (type.isUnionOrIntersection()) { + return add({ + kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection', + types: type.types.map(convert), + }) + } + if ((flags & ts.TypeFlags.TypeParameter) !== 0) { + this.fail(authoredType, 'Remote codec contains an unresolved type parameter') + } + if ((flags & ts.TypeFlags.Object) === 0) { + this.fail( + authoredType, + `Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`, + ) + } + if (this.checker.isTupleType(type)) { + const reference = type as ts.TypeReference + const target = reference.target as ts.TupleType + const arguments_ = this.checker.getTypeArguments(reference) + return add({ + kind: 'tuple', + elements: arguments_.map((argument, index) => { + const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required + return { + type: convert(argument), + optional: (elementFlags & ts.ElementFlags.Optional) !== 0, + rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0, + } + }), + }) + } + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + if (element === undefined) this.fail(authoredType, 'Remote codec array has no element type') + return add({ kind: 'array', element: convert(element) }) + } + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) { + this.fail(authoredType, 'Remote codec cannot contain callable or constructable values') + } + const members: MemberModel[] = [] + for (const property of this.checker.getPropertiesOfType(type)) { + const declaration = property.valueDeclaration ?? property.declarations?.[0] + const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType) + const symbolKey = property.getName() + members.push({ + ...EMPTY_DOCUMENTATION, + id: `${id}#${symbolKey}`, + name: symbolKey, + ...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}), + optional: (property.flags & ts.SymbolFlags.Optional) !== 0, + readonly: declaration !== undefined && hasModifier(declaration, ts.SyntaxKind.ReadonlyKeyword), + async: false, + abstract: false, + static: false, + visibility: 'public', + location: this.location(authoredType), + text: '', + kind: 'property', + type: convert(propertyType), + }) + } + for (const [index, info] of this.checker.getIndexInfosOfType(type).entries()) { + members.push({ + ...EMPTY_DOCUMENTATION, + id: `${id}#index:${String(index)}`, + name: '(index)', + optional: false, + readonly: info.isReadonly, + async: false, + abstract: false, + static: false, + visibility: 'public', + location: this.location(authoredType), + text: '', + kind: 'index', + signature: { + typeParameters: [], + parameters: [{ + name: 'key', + binding: 'identifier', + type: convert(info.keyType), + optional: false, + rest: false, + receiver: false, + }], + returns: convert(info.type), + }, + }) + } + return add({ kind: 'object', members }) + } finally { + active.delete(type) + } + } + return convert(this.checker.getTypeFromTypeNode(authoredType)) + } + + private resolvedCycleReference( + type: ts.Type, + site: ts.TypeNode, + resolvedType: TypeNodeId, + recursiveDeclarations: Map, + ): TypeNodeId { + const symbol = type.aliasSymbol ?? type.getSymbol() + if (symbol === undefined) this.fail(site, 'Remote codec contains an unnamed recursive type') + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration === undefined || isStandardLibraryFile(declaration.getSourceFile().fileName)) { + this.fail(site, `Remote codec recursive type ${resolved.name} has no workspace declaration`) + } + const owner = this.registrationForFile(declaration.getSourceFile().fileName) + if (owner === undefined) this.fail(site, `Remote codec recursive type ${resolved.name} is not owned by this face`) + let id = recursiveDeclarations.get(type) + if (id === undefined) { + id = `${this.symbolId(resolved)}#remote-codec:${resolvedType}` + recursiveDeclarations.set(type, id) + this.declarations.set(id, { + ...EMPTY_DOCUMENTATION, + id, + package: owner.name, + name: `${resolved.name}RemoteCodec`, + kind: 'alias', + abstract: false, + exported: false, + location: this.location(declaration), + text: '', + typeParameters: [], + extends: [], + implements: [], + members: [], + type: resolvedType, + }) + } + return this.addNode(site, { + kind: 'reference', + name: `${resolved.name}RemoteCodec`, + target: { kind: 'declaration', symbol: id }, + arguments: [], + }) + } + + private namedWorkspaceType(node: ts.TypeNode): ts.Symbol | undefined { + if (!ts.isTypeReferenceNode(node) && !ts.isImportTypeNode(node)) return undefined + const symbol = ts.isTypeReferenceNode(node) + ? this.checker.getSymbolAtLocation(node.typeName) + : node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier) + if (symbol === undefined) return undefined + const resolved = this.resolveSymbol(symbol) + const declaration = preferredDeclaration(resolved) + if (declaration === undefined + || isStandardLibraryFile(declaration.getSourceFile().fileName) + || this.registrationForFile(declaration.getSourceFile().fileName) === undefined) return undefined + return resolved + } + + private publicRemoteType(symbol: ts.Symbol, site: ts.Node): RemoteTypeImportModel { + const declaration = preferredDeclaration(symbol) + if (declaration === undefined) this.fail(site, `type ${symbol.name} has no declaration`) + const registration = this.registrationForFile(declaration.getSourceFile().fileName) + if (registration === undefined) this.fail(site, `type ${symbol.name} is not owned by a workspace package`) + const candidates: RemoteTypeImportModel[] = [] + for (const [subpath, target] of packageExportTargets(registration.manifest)) { + if (subpath === '.' || subpath === './package.json' || subpath === './typert' + || subpath === './client/typert' || subpath === './remote' || target.includes('*')) continue + const sourceFile = this.sourceFiles.get(realPath(sourcePathForExport(registration.root, target))) + if (sourceFile === undefined) continue + const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile) + if (moduleSymbol === undefined) continue + for (const exported of this.checker.getExportsOfModule(moduleSymbol)) { + if (this.resolveSymbol(exported) !== symbol) continue + candidates.push({ + symbol: this.symbolId(symbol), + specifier: packageExportSpecifier(registration.name, subpath), + name: exported.name, + }) + } + } + const selected = candidates.sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))[0] + if (selected === undefined) { + this.fail(site, `Remote boundary type ${symbol.name} must be exported from a public non-root type subpath`) + } + return selected + } + + private isWorkspaceClass(symbol: ts.Symbol): boolean { + const declaration = preferredDeclaration(symbol) + return declaration !== undefined + && ts.isClassDeclaration(declaration) + && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined + } + + private isTypeMetaSymbol(node: ts.Node, name: string): boolean { + const symbol = this.checker.getSymbolAtLocation(node) + if (symbol === undefined) return false + const resolved = this.resolveSymbol(symbol) + if (resolved.name !== name) return false + const declaration = preferredDeclaration(resolved) + if (declaration === undefined) return false + const registration = this.registrationForFile(declaration.getSourceFile().fileName) + if (registration?.name === '@deepseek-ai/dsh-type-meta') return true + for (let current: ts.Node | undefined = declaration; current !== undefined; current = optionalParent(current)) { + if (ts.isModuleDeclaration(current) + && ts.isStringLiteral(current.name) + && current.name.text === '@deepseek-ai/dsh-type-meta') return true + } + return false + } + + private validateInvocationIdentity(packages: readonly PackageModel[]): void { + const endpoints = new Map() + const ids = new Map() + for (const invocation of packages.flatMap(packageModel => packageModel.invocations)) { + const endpoint = `${invocation.namespace}/${invocation.method}` + const existingEndpoint = endpoints.get(endpoint) + if (existingEndpoint !== undefined) { + throw new TypertAnalysisError( + `typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote endpoint ${endpoint} conflicts with ${existingEndpoint.id}`, + ) + } + const existingId = ids.get(invocation.id) + if (existingId !== undefined) { + throw new TypertAnalysisError( + `typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote invocation id ${invocation.id} conflicts with ${existingId.id}`, + ) + } + endpoints.set(endpoint, invocation) + ids.set(invocation.id, invocation) + } + } + private collectEvents(events: ts.InterfaceDeclaration): EventModel[] { const result: EventModel[] = [] for (const member of events.members) { @@ -1015,6 +1777,11 @@ class FaceAnalyzer { ): MemberModel[] { const result: MemberModel[] = [] for (const member of members) { + if (ts.isPropertyDeclaration(member) + && memberName(member.name) === 'typertGateway' + && member.initializer !== undefined + && ts.isCallExpression(member.initializer) + && this.isTypeMetaSymbol(member.initializer.expression, 'bindTypeRTGateway')) continue const visibility = visibilityOf(member) const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword) if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue @@ -1045,17 +1812,19 @@ class FaceAnalyzer { visibility: MemberVisibility, isStatic: boolean, ): MemberBase { - const name = member.name !== undefined - ? memberName(member.name) - : ts.isCallSignatureDeclaration(member) - ? '(call)' - : ts.isConstructSignatureDeclaration(member) - ? '(construct)' - : '(index)' + const identity = member.name !== undefined + ? this.memberIdentity(member.name) + : { + name: ts.isCallSignatureDeclaration(member) + ? '(call)' + : ts.isConstructSignatureDeclaration(member) + ? '(construct)' + : '(index)', + } return { ...documentationOf(member), - id: `${ownerId}#${name}@${String(member.getStart())}`, - name, + id: `${ownerId}#${identity.name}@${String(member.getStart())}`, + ...identity, optional: 'questionToken' in member && member.questionToken !== undefined, readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword), async: hasModifier(member, ts.SyntaxKind.AsyncKeyword), @@ -1067,6 +1836,20 @@ class FaceAnalyzer { } } + private memberIdentity(name: ts.PropertyName): Pick { + if (!ts.isComputedPropertyName(name)) return { name: memberName(name) } + const expression = name.expression + if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression) + || ts.isNoSubstitutionTemplateLiteral(expression)) { + return { name: memberName(name), jsonName: expression.text } + } + const type = this.checker.getTypeAtLocation(expression) + return { + name: memberName(name), + computed: (type.flags & ts.TypeFlags.UniqueESSymbol) !== 0 ? 'symbol' : 'dynamic', + } + } + private signature( node: ts.SignatureDeclarationBase, explicitReturn: ts.TypeNode | undefined, @@ -1570,7 +2353,23 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) - && typertMode(statement) !== undefined) return true + && (typertMode(statement) !== undefined || typertServiceTag(statement) !== undefined)) return true + if (ts.isClassDeclaration(statement)) { + for (const member of statement.members) { + if (ts.isPropertyDeclaration(member) + && memberName(member.name) === 'typertGateway' + && member.initializer !== undefined + && ts.isCallExpression(member.initializer) + && expressionName(member.initializer.expression) === 'bindTypeRTGateway') return true + for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { + const expression = ts.isCallExpression(decorator.expression) + ? decorator.expression.expression + : decorator.expression + const name = expressionName(expression) + if (name === 'Remote' || name === 'RemoteContext') return true + } + } + } if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) || statement.name.text !== 'cordis' @@ -1588,6 +2387,7 @@ function hasPackageSurface(model: PackageModel): boolean { || model.events.length > 0 || model.objects.length > 0 || model.schemas.length > 0 + || model.invocations.length > 0 } function isDualFacePackage(manifest: Record): boolean { @@ -1599,7 +2399,9 @@ function isDualFacePackage(manifest: Record): boolean { function hostExportSubpaths(manifest: Record): string[] { return packageExportTargets(manifest) .map(([subpath]) => subpath) - .filter(subpath => subpath !== './client' && !subpath.startsWith('./client/')) + .filter(subpath => subpath !== './client' + && !subpath.startsWith('./client/') + && subpath !== './remote') } function clientExportSubpaths(manifest: Record): string[] { @@ -1668,6 +2470,10 @@ function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined { ?? symbol.declarations?.[0] } +function optionalParent(node: ts.Node): ts.Node | undefined { + return (node as ts.Node & { readonly parent?: ts.Node }).parent +} + function isTypeDeclaration( node: ts.Node, ): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration { @@ -1822,6 +2628,11 @@ function typertMode(node: ts.Node): 'object' | 'schema' | undefined { return undefined } +function typertServiceTag(node: ts.Node): ts.JSDocTag | undefined { + return ts.getJSDocTags(node).find(tag => tag.tagName.text === 'typert' + && (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/, 1)[0] === 'service') +} + function memberName(name: ts.PropertyName | ts.BindingName): string { if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text @@ -1829,6 +2640,26 @@ function memberName(name: ts.PropertyName | ts.BindingName): string { return name.getText() } +function stringLiteralValue(node: ts.Node | undefined): string | undefined { + return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ? node.text + : undefined +} + +function isRemoteSegment(value: string): boolean { + return value.length > 0 && !value.includes('/') +} + +function expressionName(node: ts.Expression): string | undefined { + if (ts.isIdentifier(node)) return node.text + if (ts.isPropertyAccessExpression(node)) return node.name.text + return undefined +} + +function packageExportSpecifier(packageName: string, subpath: string): string { + return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` +} + function visibilityOf(node: ts.Node): MemberVisibility { if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private' if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private' diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 1bcb1ca72a..e5c2c15a00 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -231,7 +231,7 @@ export class CordisCatalogProjector { for (const service of packageModel.services) { const declaration = this.renderer.declaration(service.symbol) if (declaration.kind !== 'class' - || !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file) + || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file) || declaration.location.file !== service.location.file) continue const doc = parseJsDoc(declaration.jsDoc ?? '').doc const source = pointer(declaration.location) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 4a09eaad68..3e79780593 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -4,11 +4,17 @@ * @module @deepseek-ai/dsh-typert-generator/emitter */ +import { Buffer } from 'node:buffer' +import { posix } from 'node:path' +import { GenMapping, addMapping, toEncodedMap } from '@jridgewell/gen-mapping' import type { DocumentationModel, FaceModel, + InvocationModel, MemberModel, PackageModel, + RemoteBoundaryModel, + RemoteTypeImportModel, SchemaModel, SymbolId, TypeDeclarationModel, @@ -29,6 +35,14 @@ export interface ModelEmitResult { readonly exports: readonly string[] readonly js: string readonly dts: string + readonly remote?: RemoteModelEmitResult +} + +/** Host-for-Client Remote contribution generated from the Host Program. */ +export interface RemoteModelEmitResult { + readonly js: string + readonly dts: string + readonly dtsMap: string } interface RuntimeMemberModel { @@ -92,7 +106,11 @@ export class FaceModelEmitter { if (packageModel === undefined) { throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`) } - const schemas = new SchemaEmitter(this.renderer, packageModel.schemas) + const schemas = new SchemaEmitter( + this.renderer, + packageModel.schemas, + invocationBoundaryRoots(packageModel.invocations), + ) const schemaArtifact = schemas.emit() const runtimeModel = this.runtimeModel(packageModel) const js = this.renderJs(packageModel, schemaArtifact, runtimeModel) @@ -103,6 +121,9 @@ export class FaceModelEmitter { exports: packageModel.schemas.map(schema => schema.export.name), js, dts, + ...(this.face.face === 'host' && packageModel.invocations.length > 0 + ? { remote: this.emitRemote(packageModel) } + : {}), } } @@ -184,6 +205,11 @@ export class FaceModelEmitter { lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`) } lines.push(' ],') + lines.push(' invocations: [') + for (const invocation of packageModel.invocations) { + lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) + } + lines.push(' ],') lines.push(` model: ${indent(model, 2).trimStart()},`) lines.push('}') return `${lines.join('\n')}\n` @@ -215,6 +241,246 @@ export class FaceModelEmitter { lines.push('export declare const TYPERT: unknown') return `${lines.join('\n')}\n` } + + private emitRemote(packageModel: PackageModel): RemoteModelEmitResult { + const schemas = new SchemaEmitter( + this.renderer, + [], + invocationBoundaryRoots(packageModel.invocations), + ).emit() + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', + ] + if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '') + lines.push(...schemas.definitions) + if (schemas.definitions.length > 0) lines.push('') + lines.push('export const TYPERT_REMOTE = {') + lines.push(` package: ${quote(packageModel.name)},`) + lines.push(' descriptors: [') + for (const invocation of packageModel.invocations) { + lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`) + } + lines.push(' ],') + lines.push('}') + lines.push('') + lines.push('export default TYPERT_REMOTE') + const declaration = this.renderRemoteDts(packageModel) + return { + js: `${lines.join('\n')}\n`, + ...declaration, + } + } + + private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string { + const lines = [ + '{', + ` id: ${quote(invocation.id)},`, + ` service: ${quote(invocation.service)},`, + ` namespace: ${quote(invocation.namespace)},`, + ` method: ${quote(invocation.method)},`, + ] + if (invocation.implementation !== undefined) { + lines.push(` implementation: ${quote(invocation.implementation)},`) + } + if (invocation.invocation.kind === 'direct') { + lines.push(' invocation: { kind: \'direct\' },') + } else { + lines.push(' invocation: {') + lines.push(' kind: \'context\',') + lines.push(` context: ${quote(invocation.invocation.context)},`) + lines.push(` wire: ${quote(invocation.invocation.wire)},`) + lines.push(` codec: ${indent(strictCodec( + invocation.invocation.boundary, + schemas.boundary(contextBoundaryKey(invocation)), + ), 4).trimStart()},`) + lines.push(' },') + } + if (invocation.scope !== undefined) { + lines.push(' scope: {') + lines.push(` context: ${quote(invocation.scope.context)},`) + lines.push(` wire: ${quote(invocation.scope.wire)},`) + lines.push(' },') + } + lines.push(' parameters: [') + invocation.parameters.forEach((parameter, index) => { + lines.push(' {') + lines.push(` name: ${quote(parameter.name)},`) + lines.push(` wire: ${quote(parameter.wire)},`) + lines.push(` source: ${quote(parameter.source)},`) + if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) + lines.push(` codec: ${indent(strictCodec( + parameter.boundary, + schemas.boundary(parameterBoundaryKey(invocation, index)), + ), 6).trimStart()},`) + lines.push(' },') + }) + lines.push(' ],') + lines.push(` result: ${indent(strictCodec( + invocation.result, + schemas.boundary(resultBoundaryKey(invocation)), + ), 2).trimStart()},`) + lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`) + lines.push('}') + return lines.join('\n') + } + + private renderRemoteDts(packageModel: PackageModel): Pick { + const imports = remoteImports(packageModel.invocations) + const referenceNames = allocateRemoteImportNames(imports) + const grouped = new Map() + for (const imported of imports) { + const values = grouped.get(imported.specifier) ?? [] + values.push({ + name: imported.name, + local: referenceNames.get(imported.symbol) as string, + }) + grouped.set(imported.specifier, values) + } + const lines = [ + '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', + 'import type {', + ' TypeRTRemoteContribution,', + '} from \'@deepseek-ai/dsh-type-meta\'', + ] + const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' }) + for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) { + const names = values.sort((left, right) => left.local.localeCompare(right.local)).map(value => + value.name === value.local ? value.name : `${value.name} as ${value.local}`) + lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`) + } + lines.push('') + lines.push('declare module \'@deepseek-ai/dsh-type-meta\' {') + const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct') + const scoped = packageModel.invocations.filter(invocation => + invocation.invocation.kind === 'context' || invocation.scope !== undefined) + if (direct.length > 0) { + for (const namespace of uniqueNamespaces(direct)) { + lines.push(` interface ${remoteNamespaceInterface(namespace)} {`) + for (const invocation of direct.filter(candidate => candidate.namespace === namespace)) { + this.pushRemoteNamespaceSignature(lines, sourceMap, packageModel, invocation, referenceNames) + } + lines.push(' }') + } + lines.push(' interface TypeRTRemoteMap {') + for (const invocation of direct) { + this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, false) + } + lines.push(' }') + lines.push(' interface TypeRTRemoteNamespaceMap {') + for (const namespace of uniqueNamespaces(direct)) { + lines.push(` ${quote(namespace)}: ${remoteNamespaceInterface(namespace)}`) + } + lines.push(' }') + } + if (scoped.length > 0) { + lines.push(' interface TypeRTRemoteContextMap {') + for (const invocation of scoped) { + this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true) + } + lines.push(' }') + } + lines.push('}') + lines.push('') + lines.push('export declare const TYPERT_REMOTE: TypeRTRemoteContribution') + lines.push('export default TYPERT_REMOTE') + lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map') + return { + dts: `${lines.join('\n')}\n`, + dtsMap: `${JSON.stringify(toEncodedMap(sourceMap))}\n`, + } + } + + private pushRemoteSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): void { + const signature = this.remoteSignature(invocation, referenceNames, scoped) + const line = ` ${signature}` + lines.push(line) + const generatedLine = lines.length + const keyLength = signature.indexOf(': (') + if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) + const source = remoteDeclarationSource(packageModel, invocation) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 }, + source, + original: { line: invocation.location.line, column: invocation.location.column - 1 }, + name: invocation.method, + }) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 + keyLength }, + }) + } + + private pushRemoteNamespaceSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + referenceNames: ReadonlyMap, + ): void { + const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + lines.push(` ${signature}`) + const generatedLine = lines.length + const source = remoteDeclarationSource(packageModel, invocation) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 }, + source, + original: { line: invocation.location.line, column: invocation.location.column - 1 }, + name: invocation.method, + }) + addMapping(sourceMap, { + generated: { line: generatedLine, column: 4 + invocation.method.length }, + }) + } + + private remoteSignature( + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): string { + const context = invocation.invocation.kind === 'context' + ? invocation.invocation.context + : invocation.scope?.context + const key = scoped + ? `${context as string}:${invocation.namespace}/${invocation.method}` + : `${invocation.namespace}/${invocation.method}` + return `${quote(key)}: ${this.remoteFunctionType(invocation, referenceNames, scoped)}` + } + + private remoteFunctionType( + invocation: InvocationModel, + referenceNames: ReadonlyMap, + scoped: boolean, + ): string { + const parameters = invocation.parameters.filter(parameter => + !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => + `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + const result = this.renderer.renderType(invocation.result.type, referenceNames) + return `(${parameters.join(', ')}) => Promise<${result}>` + } +} + +function remoteDeclarationSource(packageModel: PackageModel, invocation: InvocationModel): string { + const relativeSource = posix.relative(packageModel.root, invocation.location.file) + if (relativeSource === '' || relativeSource === '..' || relativeSource.startsWith('../') || posix.isAbsolute(relativeSource)) { + throw new TypertEmitError( + `Remote declaration ${invocation.id} is outside its package root ${packageModel.root}`, + ) + } + return posix.join('..', relativeSource) +} + +function uniqueNamespaces(invocations: readonly InvocationModel[]): string[] { + return [...new Set(invocations.map(invocation => invocation.namespace))].sort() +} + +function remoteNamespaceInterface(namespace: string): string { + return `TypeRTRemoteNamespace$${Buffer.from(namespace, 'utf8').toString('hex')}` } interface SchemaExport { @@ -226,15 +492,23 @@ interface SchemaExport { interface SchemaArtifact { readonly definitions: readonly string[] readonly exports: readonly SchemaExport[] + boundary(key: string): string +} + +interface BoundarySchemaRoot { + readonly key: string + readonly type: TypeNodeId } class SchemaEmitter { private readonly names = new Map() + private readonly boundaryNames = new Map() private readonly declarations: TypeDeclarationModel[] constructor( private readonly renderer: TypeGraphRenderer, private readonly schemas: readonly SchemaModel[], + private readonly boundaries: readonly BoundarySchemaRoot[], ) { const declarations = new Map() for (const schema of schemas) { @@ -242,6 +516,11 @@ class SchemaEmitter { declarations.set(declaration.id, declaration) } } + for (const boundary of boundaries) { + for (const declaration of renderer.declarationClosureForTypes([boundary.type])) { + declarations.set(declaration.id, declaration) + } + } this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id)) const identifiers = new Set() for (const declaration of this.declarations) { @@ -252,65 +531,92 @@ class SchemaEmitter { identifiers.add(name) this.names.set(declaration.id, name) } + for (const boundary of boundaries) { + const base = `${safeIdentifier(boundary.key)}$schema` + let name = base + let suffix = 2 + while (identifiers.has(name)) name = `${base}${String(suffix++)}` + identifiers.add(name) + this.boundaryNames.set(boundary.key, name) + } } emit(): SchemaArtifact { - const definitions = this.declarations.map((declaration) => { - if (declaration.typeParameters.length > 0) { - this.fail(declaration.name, 'generic declarations require a schema-factory projection') - } - return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}` - }) + const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration)) + for (const boundary of this.boundaries) { + definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`) + } const exports = this.schemas.map((model): SchemaExport => ({ model, exportName: safeIdentifier(model.export.name), - internalName: this.schemaName(model.symbol), + internalName: this.exportSchemaName(model), })) - return { definitions, exports } + return { + definitions, + exports, + boundary: key => this.boundaryName(key), + } } - private declarationSchema(declaration: TypeDeclarationModel): string { + private declarationDefinition(declaration: TypeDeclarationModel): string { + const name = this.schemaName(declaration.id) + if (declaration.typeParameters.length === 0) { + return `const ${name} = ${this.declarationSchema(declaration, new Map())}` + } + const parameters = declaration.typeParameters.map((parameter, index) => + [`type${String(index)}$schema`, parameter.id] as const) + const substitutions = new Map(parameters.map(([schema, id]) => [id, schema])) + return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}` + } + + private declarationSchema( + declaration: TypeDeclarationModel, + substitutions: ReadonlyMap, + ): string { if (declaration.kind === 'enum') { this.fail(declaration.name, 'enum declarations have no Zod projection') } if (declaration.kind === 'alias') { if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type') - return this.describe(this.typeSchema(declaration.type), declaration) + return this.describe(this.typeSchema(declaration.type, substitutions), declaration) } - const own = this.objectSchema(declaration.members, declaration.name) + const own = this.objectSchema(declaration.members, declaration.name, substitutions) let result = own for (const heritage of declaration.extends) { - result = `z.intersection(${this.typeSchema(heritage)}, ${result})` + result = `z.intersection(${this.typeSchema(heritage, substitutions)}, ${result})` } return this.describe(result, declaration) } - private typeSchema(id: TypeNodeId): string { + private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap = new Map()): string { const node = this.renderer.node(id) switch (node.kind) { case 'keyword': return this.keywordSchema(node.name) case 'literal': return `z.literal(${node.text})` - case 'parenthesized': return this.typeSchema(node.type) - case 'reference': return this.referenceSchema(node) + case 'parenthesized': return this.typeSchema(node.type, substitutions) + case 'reference': return this.referenceSchema(node, substitutions) case 'union': { if (node.types.length === 0) return 'z.never()' - if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId) - return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])` + if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId, substitutions) + return `z.union([${node.types.map(type => this.typeSchema(type, substitutions)).join(', ')}])` } case 'intersection': { const [head, ...tail] = node.types if (head === undefined) return 'z.unknown()' - return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head)) + return tail.reduce( + (left, right) => `z.intersection(${left}, ${this.typeSchema(right, substitutions)})`, + this.typeSchema(head, substitutions), + ) } - case 'array': return `z.array(${this.typeSchema(node.element)})` + case 'array': return `z.array(${this.typeSchema(node.element, substitutions)})` case 'tuple': { const fixed = node.elements.filter(element => !element.rest) const rest = node.elements.find(element => element.rest) - let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])` - if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})` + let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type, substitutions), element.optional)).join(', ')}])` + if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type, substitutions)})` return schema } - case 'object': return this.objectSchema(node.members, id) + case 'object': return this.objectSchema(node.members, id, substitutions) case 'operator': case 'indexed-access': case 'conditional': @@ -326,9 +632,27 @@ class SchemaEmitter { } } - private referenceSchema(node: Extract): string { + private referenceSchema( + node: Extract, + substitutions: ReadonlyMap, + ): string { if (node.target.kind === 'declaration') { - return `z.lazy(() => ${this.schemaName(node.target.symbol)})` + const name = this.schemaName(node.target.symbol) + const declaration = this.renderer.declaration(node.target.symbol) + if (declaration.typeParameters.length === 0) { + if (node.arguments.length > 0) { + this.fail(node.name, `non-generic declaration received ${String(node.arguments.length)} type arguments`) + } + return `z.lazy(() => ${name})` + } + const arguments_ = this.declarationArguments(node, declaration, substitutions) + return `z.lazy(() => ${name}(${arguments_.join(', ')}))` + } + if (node.target.kind === 'type-parameter') { + if (node.arguments.length > 0) this.fail(node.name, 'type parameter reference cannot receive type arguments') + const schema = substitutions.get(node.target.parameter) + if (schema === undefined) this.fail(node.name, 'type parameter has no schema substitution') + return schema } if (node.target.kind === 'standard') { switch (node.target.name) { @@ -336,13 +660,16 @@ class SchemaEmitter { case 'ReadonlyArray': { const element = node.arguments[0] if (element === undefined) this.fail(node.name, 'array reference has no element type') - return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray') + return this.readonly( + `z.array(${this.typeSchema(element, substitutions)})`, + node.target.name === 'ReadonlyArray', + ) } case 'Record': { const key = node.arguments[0] const value = node.arguments[1] if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types') - return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})` + return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})` } case 'Date': return 'z.date()' default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`) @@ -351,31 +678,97 @@ class SchemaEmitter { this.fail(node.name, `${node.target.kind} reference has no Zod projection`) } - private tupleRestSchema(id: TypeNodeId): string { + private declarationArguments( + node: Extract, + declaration: TypeDeclarationModel, + substitutions: ReadonlyMap, + ): string[] { + if (node.arguments.length > declaration.typeParameters.length) { + this.fail( + node.name, + `generic declaration accepts ${String(declaration.typeParameters.length)} type arguments but received ${String(node.arguments.length)}`, + ) + } + const resolved = new Map(substitutions) + const arguments_: string[] = [] + for (const [index, parameter] of declaration.typeParameters.entries()) { + const argument = node.arguments[index] + const schema = argument === undefined + ? parameter.default === undefined + ? this.fail(node.name, `missing type argument ${parameter.name}`) + : this.typeSchema(parameter.default, resolved) + : this.typeSchema(argument, substitutions) + arguments_.push(schema) + resolved.set(parameter.id, schema) + } + return arguments_ + } + + private tupleRestSchema(id: TypeNodeId, substitutions: ReadonlyMap): string { const node = this.renderer.node(id) - if (node.kind === 'array') return this.typeSchema(node.element) + if (node.kind === 'array') return this.typeSchema(node.element, substitutions) if (node.kind === 'reference' && node.target.kind === 'standard' && (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) { const element = node.arguments[0] if (element === undefined) this.fail(node.name, 'tuple rest array has no element type') - return this.typeSchema(element) + return this.typeSchema(element, substitutions) } this.fail(id, 'tuple rest element must retain an array type') } - private objectSchema(members: readonly MemberModel[], subject: string): string { + private objectSchema( + members: readonly MemberModel[], + subject: string, + substitutions: ReadonlyMap, + ): string { const properties: string[] = [] + const indices: string[] = [] + let symbolMembers = 0 for (const member of members) { if (member.static || member.visibility !== 'public') continue + if (member.computed === 'symbol') { + symbolMembers++ + continue + } + if (member.computed === 'dynamic') { + this.fail(subject, `computed member ${member.name} has no fixed JSON property name`) + } + if (member.kind === 'index') { + const parameter = member.signature.parameters[0] + if (member.signature.parameters.length !== 1 || parameter === undefined) { + this.fail(subject, 'index signature must have exactly one key parameter') + } + indices.push(this.readonly( + `z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`, + member.readonly, + )) + continue + } if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`) const property = this.describe( - this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional), + this.optional(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional), member, ) - properties.push(`${quote(member.name)}: ${property}`) + properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`) } - return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature') + // A unique-symbol-only object is a compile-time marker and imposes no JSON shape. + if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()' + const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})` + const index = indices[0] + if (index === undefined) return object + if (properties.length === 0) return index + return `z.intersection(${object}, ${index})` + } + + private exportSchemaName(model: SchemaModel): string { + const name = this.schemaName(model.symbol) + const declaration = this.renderer.declaration(model.symbol) + if (declaration.typeParameters.length > 0) { + this.fail(model.export.name, 'generic schema exports require a concrete declaration') + } + return name } private keywordSchema(name: string): string { @@ -401,6 +794,12 @@ class SchemaEmitter { return name } + private boundaryName(key: string): string { + const name = this.boundaryNames.get(key) + if (name === undefined) this.fail(key, 'invocation boundary is outside the selected schema roots') + return name + } + private describe(schema: string, documentation: DocumentationModel): string { return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})` } @@ -431,6 +830,77 @@ function documentationLiteral(documentation: DocumentationModel): DocumentationM } } +function invocationBoundaryRoots(invocations: readonly InvocationModel[]): BoundarySchemaRoot[] { + const result: BoundarySchemaRoot[] = [] + for (const invocation of invocations) { + if (invocation.invocation.kind === 'context') { + result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType }) + } + invocation.parameters.forEach((parameter, index) => { + result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType }) + }) + result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType }) + } + return result +} + +function contextBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:context` +} + +function parameterBoundaryKey(invocation: InvocationModel, index: number): string { + return `${invocation.id}:parameter:${String(index)}` +} + +function resultBoundaryKey(invocation: InvocationModel): string { + return `${invocation.id}:result` +} + +function strictCodec(boundary: RemoteBoundaryModel, schema: string): string { + return [ + '{', + ' mode: \'strict\',', + ` typeSymbol: ${quote(boundary.typeSymbol)},`, + ` schema: ${schema},`, + '}', + ].join('\n') +} + +function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImportModel[] { + const imports = new Map() + const add = (boundary: RemoteBoundaryModel): void => { + for (const imported of boundary.imports) { + const current = imports.get(imported.symbol) + if (current !== undefined + && (current.specifier !== imported.specifier || current.name !== imported.name)) { + throw new TypertEmitError(`typert Remote emitter: symbol ${imported.symbol} has inconsistent public imports`) + } + imports.set(imported.symbol, imported) + } + } + for (const invocation of invocations) { + if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary) + for (const parameter of invocation.parameters) add(parameter.boundary) + add(invocation.result) + } + return [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)) +} + +function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap { + const used = new Set(['TypeRTRemoteContribution', 'TYPERT_REMOTE']) + const names = new Map() + for (const imported of imports) { + const base = safeIdentifier(imported.name) + let name = base + let suffix = 2 + while (used.has(name)) name = `${base}$remote${String(suffix++)}` + used.add(name) + names.set(imported.symbol, name) + } + return names +} + function packageExportSpecifier(packageName: string, subpath: string): string { return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index c6b7ffbc87..7f15c8407c 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -94,6 +94,56 @@ export interface SchemaModel extends DocumentationModel { readonly type: TypeNodeId } +/** One public business type import retained for a generated Remote declaration. */ +export interface RemoteTypeImportModel { + readonly symbol: SymbolId + readonly specifier: string + readonly name: string +} + +/** One strict wire boundary and the public symbols needed to name it. */ +export interface RemoteBoundaryModel { + /** Authored public type retained for generated consumer declarations. */ + readonly type: TypeNodeId + /** Checker-resolved projection used only to emit the runtime codec. */ + readonly codecType: TypeNodeId + readonly typeSymbol: string + readonly imports: readonly RemoteTypeImportModel[] +} + +/** One ordered business argument projected onto a Remote wire field. */ +export interface InvocationParameterModel { + readonly name: string + readonly wire: string + readonly source: 'json' | 'lookup' + readonly lookup?: string + readonly boundary: RemoteBoundaryModel +} + +/** One strictly analyzed Host method exported through TypeRT Gateway. */ +export interface InvocationModel { + readonly id: string + readonly service: string + readonly namespace: string + readonly method: string + readonly implementation?: string + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly boundary: RemoteBoundaryModel + } + readonly scope?: { + readonly context: string + readonly wire: string + } + readonly parameters: readonly InvocationParameterModel[] + readonly result: RemoteBoundaryModel + readonly location: SourceLocation +} + /** Business semantics discovered in one package on one face. */ export interface PackageModel { readonly name: string @@ -103,6 +153,7 @@ export interface PackageModel { readonly events: readonly EventModel[] readonly objects: readonly ObjectModel[] readonly schemas: readonly SchemaModel[] + readonly invocations: readonly InvocationModel[] } /** One explicit import/re-export edge between independently compiled faces. */ @@ -173,6 +224,10 @@ export interface SignatureModel { export interface MemberBase extends DocumentationModel { readonly id: string readonly name: string + /** JSON property name when a literal computed key differs from source text. */ + readonly jsonName?: string + /** Non-literal computed keys; symbol keys are erased from JSON schemas. */ + readonly computed?: 'symbol' | 'dynamic' readonly optional: boolean readonly readonly: boolean readonly async: boolean diff --git a/packages/typert/generator/src/renderer.ts b/packages/typert/generator/src/renderer.ts index 8d9a3c4954..5c6fc5cb2b 100644 --- a/packages/typert/generator/src/renderer.ts +++ b/packages/typert/generator/src/renderer.ts @@ -81,32 +81,35 @@ export class TypeGraphRenderer { /** * Render one type expression from the retained source structure. * @param id - type node id. + * @param references - optional generated names for declaration references. * @returns TypeScript type text. */ - renderType(id: TypeNodeId): string { + renderType(id: TypeNodeId, references?: ReadonlyMap): string { const node = this.node(id) switch (node.kind) { case 'keyword': return node.name case 'literal': return node.text - case 'parenthesized': return `(${this.renderType(node.type)})` + case 'parenthesized': return `(${this.renderType(node.type, references)})` case 'reference': { const name = node.target.kind === 'type-parameter' ? this.parameterNames.get(node.target.parameter) ?? node.name - : node.name + : node.target.kind === 'declaration' + ? references?.get(node.target.symbol) ?? node.name + : node.name return node.arguments.length === 0 ? name - : `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `${name}<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` } - case 'union': return node.types.map(type => this.renderType(type)).join(' | ') - case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ') + case 'union': return node.types.map(type => this.renderType(type, references)).join(' | ') + case 'intersection': return node.types.map(type => this.renderType(type, references)).join(' & ') case 'array': { - const element = this.renderType(node.element) + const element = this.renderType(node.element, references) const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element return `${wrapped}[]` } case 'tuple': { const elements = node.elements.map((element) => { - const type = this.renderType(element.type) + const type = this.renderType(element.type, references) if (element.name !== undefined) { return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}` } @@ -114,34 +117,34 @@ export class TypeGraphRenderer { }) return `[${elements.join(', ')}]` } - case 'object': return this.renderObject(node.members) - case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` - case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` - case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]` - case 'operator': return `${node.operator} ${this.renderType(node.type)}` + case 'object': return this.renderObject(node.members, references) + case 'function': return `${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}` + case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}` + case 'indexed-access': return `${this.renderType(node.object, references)}[${this.renderType(node.index, references)}]` + case 'operator': return `${node.operator} ${this.renderType(node.type, references)}` case 'conditional': { - return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}` + return `${this.renderType(node.check, references)} extends ${this.renderType(node.extends, references)} ? ${this.renderType(node.whenTrue, references)} : ${this.renderType(node.whenFalse, references)}` } - case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}` + case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false, references)}` case 'mapped': { const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly ' const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?' if (node.parameter.constraint === undefined) { throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`) } - const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}` - const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}` - const value = node.value === undefined ? 'unknown' : this.renderType(node.value) + const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint, references)}` + const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType, references)}` + const value = node.value === undefined ? 'unknown' : this.renderType(node.value, references) return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }` } case 'template-literal': { - const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('') + const spans = node.spans.map(span => `\${${this.renderType(span.type, references)}}${escapeTemplate(span.text)}`).join('') return `\`${escapeTemplate(node.head)}${spans}\`` } case 'type-query': { const argumentsText = node.arguments.length === 0 ? '' - : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` return `typeof ${node.expression}${argumentsText}` } case 'import-type': { @@ -149,14 +152,14 @@ export class TypeGraphRenderer { const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}` const argumentsText = node.arguments.length === 0 ? '' - : `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>` + : `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>` return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}` } case 'predicate': { const assertion = node.asserts ? 'asserts ' : '' return node.type === undefined ? `${assertion}${node.parameter}` - : `${assertion}${node.parameter} is ${this.renderType(node.type)}` + : `${assertion}${node.parameter} is ${this.renderType(node.type, references)}` } case 'this': return 'this' default: return assertNever(node) @@ -166,34 +169,36 @@ export class TypeGraphRenderer { /** * Render a callable signature without a member name. * @param signature - modeled signature. + * @param references - optional generated names for declaration references. * @returns parameter list and return type. */ - renderSignature(signature: SignatureModel): string { - return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}` + renderSignature(signature: SignatureModel, references?: ReadonlyMap): string { + return `${this.renderSignatureHead(signature, references)}: ${this.renderType(signature.returns, references)}` } /** * Render one class/interface member as a body-free declaration. * @param member - modeled member. * @param sourceModifiers - retain source-only modifiers for reflection text. + * @param references - optional generated names for declaration references. * @returns one-line TypeScript member text. */ - renderMember(member: MemberModel, sourceModifiers = false): string { + renderMember(member: MemberModel, sourceModifiers = false, references?: ReadonlyMap): string { if (sourceModifiers) return member.text const name = renderPropertyName(member.name) const optional = member.optional ? '?' : '' const readonly = member.readonly ? 'readonly ' : '' const abstract = member.abstract ? 'abstract ' : '' switch (member.kind) { - case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}` - case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}` - case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}` - case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}` - case 'call': return this.renderSignature(member.signature) - case 'construct': return `new ${this.renderSignature(member.signature)}` + case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type, references)}` + case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature, references)}` + case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature, references)}` + case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature, references)}` + case 'call': return this.renderSignature(member.signature, references) + case 'construct': return `new ${this.renderSignature(member.signature, references)}` case 'index': { - const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ') - return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}` + const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ') + return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns, references)}` } default: return assertNever(member) } @@ -290,38 +295,42 @@ export class TypeGraphRenderer { return this.graph.declarations.filter(declaration => found.has(declaration.id)) } - private renderSignatureHead(signature: SignatureModel): string { - return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})` + private renderSignatureHead(signature: SignatureModel, references?: ReadonlyMap): string { + return `${this.renderTypeParameters(signature.typeParameters, references)}(${signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')})` } - private renderReturn(signature: SignatureModel): string { - return `: ${this.renderType(signature.returns)}` + private renderReturn(signature: SignatureModel, references?: ReadonlyMap): string { + return `: ${this.renderType(signature.returns, references)}` } - private renderParameter(parameter: ParameterModel): string { + private renderParameter(parameter: ParameterModel, references?: ReadonlyMap): string { const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : '' const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}` - return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}` + return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type, references)}${initializer}` } - private renderTypeParameters(parameters: readonly TypeParameterModel[]): string { + private renderTypeParameters(parameters: readonly TypeParameterModel[], references?: ReadonlyMap): string { return parameters.length === 0 ? '' - : `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>` + : `<${parameters.map(parameter => this.renderTypeParameter(parameter, true, references)).join(', ')}>` } - private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string { + private renderTypeParameter( + parameter: TypeParameterModel, + includeDefault: boolean, + references?: ReadonlyMap, + ): string { const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} ` const constModifier = parameter.const ? 'const ' : '' - const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}` - const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}` + const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint, references)}` + const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default, references)}` return `${constModifier}${variance}${parameter.name}${constraint}${fallback}` } - private renderObject(members: readonly MemberModel[]): string { + private renderObject(members: readonly MemberModel[], references?: ReadonlyMap): string { if (members.length === 0) return '{}' - return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }` + return `{ ${members.map(member => `${this.renderMember(member, false, references)};`).join(' ')} }` } private indexParameters(parameters: readonly TypeParameterModel[]): void { diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index 9254eeb16d..a5c6ef93e2 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -2,7 +2,7 @@ * Optional tsdown (rolldown) plugin face of the typert generator. When added * to a workspace tsdown config, it runs after each opted-in package bundle is * written and re-emits its model-driven face artifact at the package output - * root. Packages without a Typert export are skipped. + * root. Packages without a Typert or Remote export are skipped. * @module @deepseek-ai/dsh-typert-generator/tsdown */ @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' +import type { TypertFace } from './model.ts' /** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ interface TypertPlugin { @@ -17,21 +18,37 @@ interface TypertPlugin { writeBundle: (options: { dir?: string }) => void } +/** Generation scope selected by a tsdown build phase. */ +export interface TypertPluginOptions { + /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ + readonly mode?: 'package' | 'workspace' + /** Independent TypeScript program faces included in this phase. */ + readonly faces?: readonly TypertFace[] +} + /** * Create the typert generation plugin for the root tsdown config. - * @returns a rolldown-compatible plugin that emits `lib/typert..js` and `.d.ts` for contributing packages. + * @param pluginOptions - package/workspace emission mode and independent program faces. + * @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts. */ -export function typertPlugin(): TypertPlugin { +export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin { const artifactsByRoot = new Map() + const emittedWorkspaces = new Set() return { name: 'dsh-typert-generator', - writeBundle(options) { + writeBundle(bundleOptions) { // options.dir is the package's absolute outDir (/lib); its // nearest package.json owns the bundle even when a custom config writes // a nested output such as /lib/dev. - if (options.dir === undefined) return - const root = workspaceRoot(options.dir) - const packageDir = packageRoot(options.dir, root) + if (bundleOptions.dir === undefined) return + const root = workspaceRoot(bundleOptions.dir) + if (emittedWorkspaces.has(root)) return + if (pluginOptions.mode === 'workspace') { + emitWorkspace(root, pluginOptions.faces) + emittedWorkspaces.add(root) + return + } + const packageDir = packageRoot(bundleOptions.dir, root) if (packageDir === undefined) return const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { name?: string @@ -40,22 +57,54 @@ export function typertPlugin(): TypertPlugin { if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return let artifacts = artifactsByRoot.get(root) if (artifacts === undefined) { - artifacts = new WorkspaceTypertGenerator(root).generate() + const generator = new WorkspaceTypertGenerator(root) + artifacts = pluginOptions.faces === undefined + ? generator.generate() + : generator.generate(undefined, pluginOptions.faces) artifactsByRoot.set(root, artifacts) } - const output = join(packageDir, 'lib') - mkdirSync(output, { recursive: true }) - for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) { - writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) - writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) - } + emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name)) }, } + + function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void { + const generator = new WorkspaceTypertGenerator(root) + const packages = generator.discover(faces) + .filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports)) + .map(candidate => candidate.package) + if (packages.length === 0) return + for (const artifact of generator.generate(packages, faces)) { + emitArtifacts(join(root, artifact.packageRoot), [artifact]) + } + } +} + +function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { + const output = join(packageDir, 'lib') + mkdirSync(output, { recursive: true }) + for (const artifact of artifacts) { + writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) + writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) + if (artifact.remote !== undefined) { + writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) + writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) + writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) + } + } +} + +function readManifest(packageDir: string): { name?: string; exports?: unknown } { + return JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + name?: string + exports?: unknown + } } function hasTypertExport(exportsField: unknown): boolean { if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false - return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert') + return Object.hasOwn(exportsField, './typert') + || Object.hasOwn(exportsField, './client/typert') + || Object.hasOwn(exportsField, './remote') } function packageRoot(start: string, workspace: string): string | undefined { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 6153a0241a..c79861a796 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -9,6 +9,7 @@ import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts' import type { DiscoveredTypertPackage } from './analyzer.ts' import { FaceModelEmitter } from './emitter.ts' import type { ModelEmitResult } from './emitter.ts' +import type { TypertFace } from './model.ts' /** One emitted artifact paired with its source package root. */ export interface WorkspaceEmitResult extends ModelEmitResult { @@ -26,20 +27,29 @@ export class WorkspaceTypertGenerator { /** * Find public package faces that contribute Cordis services/events or * explicitly tagged Typert roots. + * @param faces - optional independent program faces to inspect. * @returns discovered packages in stable package-name order. */ - discover(): DiscoveredTypertPackage[] { - return new WorkspaceAnalyzer({ root: this.root }).discoverPackages() + discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] { + return new WorkspaceAnalyzer({ + root: this.root, + ...(faces === undefined ? {} : { faces }), + }).discoverPackages() } /** * Generate all discovered contributors, or an explicit package subset. * @param packages - optional exact package names for a focused pass. + * @param faces - optional independent program faces to analyze. * @returns one artifact per package face. */ - generate(packages?: readonly string[]): WorkspaceEmitResult[] { - const selected = packages ?? this.discover().map(candidate => candidate.package) - const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze() + generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] { + const selected = packages ?? this.discover(faces).map(candidate => candidate.package) + const workspace = new WorkspaceAnalyzer({ + root: this.root, + packages: selected, + ...(faces === undefined ? {} : { faces }), + }).analyze() const artifacts: WorkspaceEmitResult[] = [] for (const face of workspace.faces) { const emitter = new FaceModelEmitter(face) @@ -80,6 +90,28 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } + if (artifact.remote === undefined) return + const remoteExpected = { + types: './lib/typert.remote-client.d.ts', + default: './lib/typert.remote-client.js', + } + const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' + ? (manifest.exports as Record)['./remote'] + : undefined + if (!sameExport(remoteActual, remoteExpected)) { + throw new TypertAnalysisError( + `typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`, + ) + } + for (const file of [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + ]) { + if (!files.includes(file)) { + throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`) + } + } } } diff --git a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap index aad86b0102..bcc28cd8b2 100644 --- a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap +++ b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap @@ -17,6 +17,8 @@ export const TYPERT = { schemas: [ { name: 'Payload', schema: Payload }, ], + invocations: [ + ], model: { "services": [ { @@ -3815,6 +3817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756", + "jsonName": "computed", "kind": "property", "location": { "column": 5, @@ -5634,6 +5637,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "symbol": "@fixture/host:packages/host/src/models.ts#Variance", }, ], + "invocations": [], "name": "@fixture/host", "objects": [ { @@ -6449,6 +6453,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "symbol": ":../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType", }, ], + "invocations": [], "name": "@fixture/client", "objects": [], "root": "packages/client", diff --git a/packages/typert/generator/tests/fixtures/remote-model/package.json b/packages/typert/generator/tests/fixtures/remote-model/package.json new file mode 100644 index 0000000000..00ac86bdcc --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fixture/remote-workspace", + "private": true, + "type": "module" +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json new file mode 100644 index 0000000000..bf6b2bd110 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fixture/domain", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts" + } +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts new file mode 100644 index 0000000000..e5c2850cf2 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts @@ -0,0 +1,19 @@ +import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' +import type { AgentId } from './types.ts' + +/** Host-only live Agent object. */ +export class Agent { + constructor(readonly id: AgentId) {} +} + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + agent: TypeRTLookup + } + + interface TypeRTContextMap { + agent: TypeRTContext + } +} + +export type { AgentId } from './types.ts' diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts new file mode 100644 index 0000000000..944201e82a --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts @@ -0,0 +1,2 @@ +/** Stable Agent identity crossing the Remote boundary. */ +export type AgentId = string diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json new file mode 100644 index 0000000000..1ddc9b1a60 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json new file mode 100644 index 0000000000..b7e0631a0a --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json @@ -0,0 +1,24 @@ +{ + "name": "@fixture/remote", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + } + }, + "files": [ + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map" + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts new file mode 100644 index 0000000000..816a13a5a7 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -0,0 +1,30 @@ +import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import type { Agent } from '@fixture/domain' +import type { + CreateGoalRequest, + CreateGoalResult, + RenameGoalRequest, + RenameGoalResult, +} from './types.ts' + +/** Remote-only business Service with no Cordis declaration merge. */ +export class GoalService { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + async create(agent: Agent, request: CreateGoalRequest): Promise { + return { ref: `${agent.id}:${request.title}` } + } + + @RemoteContext('agent') + rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } +} + +export type { + CreateGoalRequest, + CreateGoalResult, + RenameGoalRequest, + RenameGoalResult, +} from './types.ts' diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts new file mode 100644 index 0000000000..88493325f8 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts @@ -0,0 +1,20 @@ +/** Input accepted by Goal creation. */ +export interface CreateGoalRequest { + readonly title: string +} + +/** Wire-safe Goal creation result. */ +export interface CreateGoalResult { + readonly ref: string +} + +/** Input accepted by scoped Goal renaming. */ +export interface RenameGoalRequest { + readonly ref: string + readonly title: string +} + +/** Wire-safe Goal rename result. */ +export interface RenameGoalResult { + readonly renamed: boolean +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json new file mode 100644 index 0000000000..534b3c3d75 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src"], + "references": [ + { "path": "../domain" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json new file mode 100644 index 0000000000..4aaf57160d --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "composite": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "ignoreDeprecations": "6.0", + "paths": { + "@deepseek-ai/dsh-type-meta": ["./type-meta.d.ts"], + "@fixture/domain": ["./packages/domain/src/index.ts"], + "@fixture/domain/*": ["./packages/domain/src/*"], + "@fixture/remote": ["./packages/remote/src/index.ts"], + "@fixture/remote/*": ["./packages/remote/src/*"] + }, + "skipLibCheck": true + } +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json new file mode 100644 index 0000000000..7797b7ff29 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/domain" }, + { "path": "./packages/remote" } + ] +} diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts new file mode 100644 index 0000000000..f8e84bbe90 --- /dev/null +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -0,0 +1,45 @@ +declare module '@deepseek-ai/dsh-type-meta' { + export interface TypeRTLookup { + readonly host: Host + readonly wire: Wire + } + + export interface TypeRTContext { + readonly wire: Wire + } + + export interface TypeRTLookupMap {} + export interface TypeRTContextMap {} + export interface TypeRTRemoteMap {} + export interface TypeRTRemoteContextMap {} + + export type TypeRTRemoteNamespace = { + [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteMap[Endpoint] + } + + export interface TypeRTRemoteNamespaceMap {} + + export interface TypeRTRemoteContribution { + readonly package: string + readonly descriptors: readonly unknown[] + } + + export function bindTypeRTGateway( + service: Service, + serviceKey: string, + options?: { readonly namespace?: string }, + ): { readonly service: Service; readonly serviceKey: string; readonly namespace: string } + + export function Remote( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void + + export function RemoteContext(key: Extract): + ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ) => void +} diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts new file mode 100644 index 0000000000..90056e673e --- /dev/null +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -0,0 +1,486 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import ts from 'typescript' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceAnalyzer } from '../src/analyzer.ts' +import type { InvocationModel } from '../src/model.ts' +import { WorkspaceTypertGenerator } from '../src/workspace.ts' + +const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model') +const temporaryRoots: string[] = [] + +interface RuntimeSchema { + safeParse(value: unknown): { readonly success: boolean } +} + +interface RuntimeDescriptor { + readonly id: string + readonly parameters: readonly { + readonly wire: string + readonly codec: { readonly schema: RuntimeSchema } + }[] + readonly result: { readonly schema: RuntimeSchema } +} + +interface RuntimeRemoteModule { + readonly TYPERT_REMOTE: { + readonly package: string + readonly descriptors: readonly RuntimeDescriptor[] + } +} + +interface RemoteDeclarationMap { + readonly file: string + readonly names: readonly string[] + readonly sources: readonly string[] +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('Remote model generation', { timeout: 60_000 }, () => { + it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => { + const generator = new WorkspaceTypertGenerator(fixtureRoot) + + expect(generator.discover()).toEqual([{ + package: '@fixture/remote', + root: 'packages/remote', + faces: ['host'], + }]) + + const [artifact] = generator.generate() + expect(artifact).toBeDefined() + expect(artifact).toMatchObject({ + package: '@fixture/remote', + face: 'host', + packageRoot: 'packages/remote', + }) + + const model = remotePackage(fixtureRoot) + expect(model.services).toEqual([]) + expect(model.invocations).toHaveLength(2) + expect(model.invocations[0]).toMatchObject({ + id: '@fixture/remote#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + boundary: { typeSymbol: '@fixture/domain/types#AgentId' }, + }, + { + name: 'request', + wire: 'request', + source: 'json', + boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' }, + }, + ], + result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, + }) + expect(model.invocations[1]).toMatchObject({ + id: '@fixture/remote#goals/rename', + service: 'goals', + namespace: 'goals', + method: 'rename', + invocation: { + kind: 'context', + context: 'agent', + wire: 'agentId', + boundary: { typeSymbol: '@fixture/domain/types#AgentId' }, + }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + boundary: { typeSymbol: '@fixture/remote/types#RenameGoalRequest' }, + }], + result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' }, + }) + + expect(artifact?.js).toContain('invocations: [') + expect(artifact?.remote?.dts).toContain( + "'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise", + ) + expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') + expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") + expect(artifact?.remote?.dts).toContain( + "'agent:goals/create': (request: CreateGoalRequest) => Promise", + ) + expect(artifact?.remote?.dts).toContain( + "'agent:goals/rename': (request: RenameGoalRequest) => Promise", + ) + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') + const create = generated.TYPERT_REMOTE.descriptors[0] + expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true) + expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) + expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) + expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false) + + const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap + expect(declarationMap).toMatchObject({ + file: 'typert.remote-client.d.ts', + sources: ['../src/index.ts'], + }) + expect(declarationMap.names).toContain('create') + + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) + }) + + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** Recursive JSON fixture used by the concrete codec projection. */ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json } + +/** Merge-extensible operation table represented by concrete fixture entries. */ +export interface GenericRemoteMap { + ship: { + readonly request: { readonly count: number; readonly meta: Json } + readonly result: { readonly accepted: boolean } + } + cancel: { + readonly request: { readonly reason: string } + readonly result: { readonly cancelled: boolean } + } +} + +type GenericRemoteKey = Extract +type RequestOf = GenericRemoteMap[K] extends { readonly request: infer Request } + ? Request + : never +type ResultOf = GenericRemoteMap[K] extends { readonly result: infer Result } + ? Result + : never + +/** Strict request union retained in the generated Client declaration. */ +export type GenericRequest = { + [K in GenericRemoteKey]: { readonly kind: K; readonly payload: RequestOf } +}[GenericRemoteKey] + +/** Strict result union retained in the generated Client declaration. */ +export type GenericResult = { + [K in GenericRemoteKey]: { readonly kind: K; readonly value: ResultOf } +}[GenericRemoteKey] +`) + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' RenameGoalResult,\n', + ' RenameGoalResult,\n GenericRequest,\n GenericResult,\n', + ) + .replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote + dispatch(request: GenericRequest): GenericResult { + if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } } + return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } } + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "'goals/dispatch': (request: GenericRequest) => Promise", + ) + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch')) + const schema = dispatch?.parameters[0]?.codec.schema + expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true) + expect(schema?.safeParse({ kind: 'ship', payload: { count: '2', meta: {} } }).success).toBe(false) + expect(schema?.safeParse({ kind: 'cancel', payload: { reason: 'obsolete' } }).success).toBe(true) + expect(schema?.safeParse({ kind: 'unknown', payload: {} }).success).toBe(false) + expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true) + expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false) + }) + + it.each([ + { + name: 'missing binding', + edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''), + message: 'Remote methods require readonly typertGateway', + }, + { + name: 'private method', + edit: (source: string) => source.replace(' async create(', ' private async create('), + message: 'Remote decorators require a public instance method', + }, + { + name: 'static method', + edit: (source: string) => source.replace(' async create(', ' static async create('), + message: 'Remote decorators require a public instance method', + }, + { + name: 'abstract method', + edit: (source: string) => source + .replace('export class GoalService', 'export abstract class GoalService') + .replace( + ' async create(agent: Agent, request: CreateGoalRequest): Promise {\n return { ref: `${agent.id}:${request.title}` }\n }', + ' abstract create(agent: Agent, request: CreateGoalRequest): Promise', + ), + message: 'Remote methods must have a concrete implementation', + }, + { + name: 'generic method', + edit: (source: string) => source.replace(' async create(', ' async create('), + message: 'generic Remote methods are not supported', + }, + { + name: 'destructured parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', '{ title }: CreateGoalRequest'), + message: 'Remote parameters must use identifier bindings', + }, + { + name: 'rest parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', '...request: [CreateGoalRequest]'), + message: 'Remote parameters cannot be rest parameters', + }, + { + name: 'default parameter', + edit: (source: string) => source.replace( + 'request: CreateGoalRequest', + "request: CreateGoalRequest = { title: '' }", + ), + message: 'Remote parameters cannot have default values', + }, + { + name: 'optional parameter', + edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), + message: 'Remote parameters cannot be optional', + }, + ])('rejects $name', ({ edit, message }) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', edit) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + + it('rejects a workspace class parameter without a lookup declaration', () => { + const root = copyFixture() + editFile(root, 'packages/domain/src/index.ts', source => source.replace( + ' interface TypeRTLookupMap {\n agent: TypeRTLookup\n }\n\n', + '', + )) + + expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/) + }) + + it('rejects a Remote Context without a static Context declaration', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/) + }) + + it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => { + const root = copyFixture() + editFile(root, 'packages/domain/src/types.ts', source => `${source}\n/** Deliberately distinct Context identity for the failure fixture. */\nexport type OtherAgentId = string\n`) + editFile(root, 'packages/domain/src/index.ts', source => source + .replace("import type { AgentId } from './types.ts'", "import type { AgentId, OtherAgentId } from './types.ts'") + .replace('agent: TypeRTContext', 'agent: TypeRTContext')) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote scope agent wire type .* does not match lookup wire type/) + }) + + it('rejects duplicate endpoints across Remote services', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => `${source} +export class DuplicateGoalService { + readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' }) + + @Remote + create(request: CreateGoalRequest): CreateGoalResult { + return { ref: request.title } + } +} +`) + + expect(() => analyzeRemote(root, false)).toThrow(/Remote endpoint goals\/create conflicts/) + }) +}) + +function analyzeRemote(root: string, checkDiagnostics = true): ReturnType { + return new WorkspaceAnalyzer({ root, checkDiagnostics }).analyze() +} + +function remotePackage(root: string): { + readonly services: readonly unknown[] + readonly invocations: readonly InvocationModel[] +} { + const host = analyzeRemote(root).faces.find(face => face.face === 'host') + const packageModel = host?.packages.find(candidate => candidate.name === '@fixture/remote') + if (packageModel === undefined) throw new Error('Remote fixture package was not modeled on the host face') + return packageModel +} + +function copyFixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-')) + cpSync(fixtureRoot, root, { recursive: true }) + temporaryRoots.push(root) + return root +} + +function editFile(root: string, relativePath: string, edit: (source: string) => string): void { + const path = join(root, relativePath) + const source = readFileSync(path, 'utf8') + const result = edit(source) + if (result === source) throw new Error(`fixture edit made no change to ${relativePath}`) + writeFileSync(path, result) +} + +function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void { + if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') + if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') + const consumerRoot = copyFixture() + const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts') + const declarationMapPath = `${declarationPath}.map` + const consumerPath = join(consumerRoot, 'consumer.ts') + mkdirSync(join(consumerRoot, 'packages/remote/lib'), { recursive: true }) + writeFileSync(declarationPath, dts, { flush: true }) + writeFileSync(declarationMapPath, dtsMap, { flush: true }) + assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot) + const consumerSource = ` +import remote from '@fixture/remote/remote' +import type { + TypeRTRemoteContribution, + TypeRTRemoteContextMap, + TypeRTRemoteMap, + TypeRTRemoteNamespaceMap, +} from '@deepseek-ai/dsh-type-meta' +import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' + +const contribution: TypeRTRemoteContribution = remote +declare const create: TypeRTRemoteMap['goals/create'] +declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] +declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] +const created: Promise = create('agent-1', { title: 'ship' }) +const createdScoped: Promise = createScoped({ title: 'ship' }) +const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) +declare const ctx: { api: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) +void contribution +void created +void createdScoped +void renamed +void navigated +` + writeFileSync(consumerPath, consumerSource) + const configPath = join(consumerRoot, 'tsconfig.consumer.json') + writeFileSync(configPath, JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + composite: false, + skipLibCheck: false, + paths: { + '@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'], + '@fixture/domain/types': ['./packages/domain/src/types.ts'], + '@fixture/remote/types': ['./packages/remote/src/types.ts'], + '@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'], + }, + }, + files: ['./consumer.ts'], + }, null, 2)) + const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) + const program = ts.createProgram(parsed.fileNames, parsed.options) + const diagnostics = ts.getPreEmitDiagnostics(program) + expect(diagnostics, formatDiagnostics(diagnostics)).toEqual([]) + + const languageService = ts.createLanguageService({ + getCompilationSettings: () => parsed.options, + getCurrentDirectory: () => consumerRoot, + getDefaultLibFileName: options => ts.getDefaultLibFilePath(options), + getScriptFileNames: () => parsed.fileNames, + getScriptSnapshot: (fileName) => { + const source = ts.sys.readFile(fileName) + return source === undefined ? undefined : ts.ScriptSnapshot.fromString(source) + }, + getScriptVersion: () => '0', + directoryExists: path => ts.sys.directoryExists(path), + fileExists: path => ts.sys.fileExists(path), + getDirectories: path => ts.sys.getDirectories(path), + readDirectory: (path, extensions, exclude, include, depth) => + ts.sys.readDirectory(path, extensions, exclude, include, depth), + readFile: path => ts.sys.readFile(path), + realpath: path => ts.sys.realpath?.(path) ?? path, + }) + const navigation = 'ctx.api.goals.create' + const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 + const definitions = languageService.getDefinitionAtPosition(consumerPath, position) + const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) + if (generatedDefinition === undefined) { + throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`) + } + const sourceMapper = (languageService as unknown as { + getSourceMapper(): { + tryGetSourcePosition(location: { readonly fileName: string; readonly pos: number }): + { readonly fileName: string; readonly pos: number } | undefined + } + }).getSourceMapper() + const definition = sourceMapper.tryGetSourcePosition({ + fileName: generatedDefinition.fileName, + pos: generatedDefinition.textSpan.start, + }) + languageService.dispose() + if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) { + throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`) + } + const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8') + expect(hostSource.slice(definition.pos, definition.pos + generatedDefinition.textSpan.length)).toBe('create') +} + +function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): void { + const consumerPath = join(consumerRoot, 'consumer-without-remote.ts') + writeFileSync(consumerPath, ` +import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta' +declare const ctx: { api: TypeRTRemoteNamespaceMap } +ctx.api.goals.create('agent-1', { title: 'must not compile' }) +`) + const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json') + writeFileSync(configPath, JSON.stringify({ + extends: './tsconfig.base.json', + compilerOptions: { + composite: false, + skipLibCheck: false, + paths: { + '@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'], + }, + }, + files: ['./consumer-without-remote.ts'], + }, null, 2)) + const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) + const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options)) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.code).toBe(2339) + expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist") +} + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: file => file, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', + }) +} diff --git a/packages/typert/generator/tests/schema-emitter.spec.ts b/packages/typert/generator/tests/schema-emitter.spec.ts index 7457c4b85f..16ac97b7ca 100644 --- a/packages/typert/generator/tests/schema-emitter.spec.ts +++ b/packages/typert/generator/tests/schema-emitter.spec.ts @@ -7,6 +7,7 @@ import type { FaceModel, KeywordTypeName, MemberModel, + SignatureMemberModel, SignatureModel, TypeDeclarationModel, TypeNodeModel, @@ -356,6 +357,149 @@ describe('SchemaEmitter supported projection matrix', () => { expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false) }) + it('instantiates generic aliases, nested references, defaults, and recursive declarations', async () => { + const box = declaration('Box', 'interface', { + typeParameters: [{ id: 'box:value', name: 'Value', const: false }], + members: [property('value', 'box:value-reference')], + }) + const wrapper = declaration('Wrapper', 'alias', { + typeParameters: [ + { id: 'wrapper:value', name: 'Value', const: false }, + { id: 'wrapper:items', name: 'Items', const: false, default: 'wrapper:default-items' }, + ], + type: 'wrapper:box-reference', + }) + const recursive = declaration('Recursive', 'interface', { + typeParameters: [{ id: 'recursive:value', name: 'Value', const: false }], + members: [ + property('value', 'recursive:value-reference'), + property('next', 'recursive:self-reference', { optional: true }), + ], + }) + const schema = await loadSchema(emit([ + { + id: 'root', + kind: 'object', + members: [ + property('wrapped', 'root:wrapper-reference'), + property('recursive', 'root:recursive-reference'), + ], + }, + { + id: 'root:wrapper-reference', + kind: 'reference', + name: 'Wrapper', + target: { kind: 'declaration', symbol: 'Wrapper' }, + arguments: ['string'], + }, + { + id: 'root:recursive-reference', + kind: 'reference', + name: 'Recursive', + target: { kind: 'declaration', symbol: 'Recursive' }, + arguments: ['number'], + }, + { + id: 'wrapper:box-reference', + kind: 'reference', + name: 'Box', + target: { kind: 'declaration', symbol: 'Box' }, + arguments: ['wrapper:items-reference'], + }, + { + id: 'wrapper:default-items', + kind: 'reference', + name: 'ReadonlyArray', + target: { kind: 'standard', name: 'ReadonlyArray' }, + arguments: ['wrapper:value-reference'], + }, + { + id: 'wrapper:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'wrapper:value' }, + arguments: [], + }, + { + id: 'wrapper:items-reference', + kind: 'reference', + name: 'Items', + target: { kind: 'type-parameter', parameter: 'wrapper:items' }, + arguments: [], + }, + { + id: 'box:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'box:value' }, + arguments: [], + }, + { + id: 'recursive:value-reference', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'recursive:value' }, + arguments: [], + }, + { + id: 'recursive:self-reference', + kind: 'reference', + name: 'Recursive', + target: { kind: 'declaration', symbol: 'Recursive' }, + arguments: ['recursive:value-reference'], + }, + keyword('string', 'string'), + keyword('number', 'number'), + ], undefined, [box, wrapper, recursive])) + + expect(schema.safeParse({ + wrapped: { value: ['one', 'two'] }, + recursive: { value: 1, next: { value: 2 } }, + }).success).toBe(true) + expect(schema.safeParse({ + wrapped: { value: [1] }, + recursive: { value: 1 }, + }).success).toBe(false) + expect(schema.safeParse({ + wrapped: { value: ['one'] }, + recursive: { value: 'one' }, + }).success).toBe(false) + }) + + it('erases unique-symbol nominal members without naming a branding utility', async () => { + const nominal = declaration('Nominal', 'alias', { + typeParameters: [{ id: 'nominal:brand', name: 'Brand', const: false }], + type: 'nominal:intersection', + }) + const symbolMember = { + ...property('[TOKEN]', 'nominal:brand-reference', { readonly: true }), + computed: 'symbol', + } as const + const schema = await loadSchema(emit([ + { + id: 'root', + kind: 'reference', + name: 'Nominal', + target: { kind: 'declaration', symbol: 'Nominal' }, + arguments: ['brand'], + }, + { id: 'brand', kind: 'literal', value: 'Fixture', text: "'Fixture'" }, + { id: 'nominal:intersection', kind: 'intersection', types: ['string', 'nominal:marker'] }, + keyword('string', 'string'), + { id: 'nominal:marker', kind: 'object', members: [symbolMember] }, + { + id: 'nominal:brand-reference', + kind: 'reference', + name: 'Brand', + target: { kind: 'type-parameter', parameter: 'nominal:brand' }, + arguments: [], + }, + ], undefined, [nominal])) + + expect(schema.safeParse('fixture-id').success).toBe(true) + expect(schema.safeParse(1).success).toBe(false) + }) + it('classifies every TypeNode kind and executes every supported kind', () => { const expected = Object.entries(ZOD_NODE_SUPPORT) .filter(([, support]) => support === 'supported') @@ -373,7 +517,6 @@ describe('SchemaEmitter unsupported projection matrix', () => { }) it.each([ - ['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }], ['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }], ['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }], ] as const)('rejects %s references explicitly', (kind, target) => { @@ -386,7 +529,33 @@ describe('SchemaEmitter unsupported projection matrix', () => { }])).toThrow(`typert Zod emitter: Value: ${kind} reference has no Zod projection`) }) - it('rejects unsupported standard references, generic declarations, and enums', () => { + it('rejects unbound type parameters, incomplete generic applications, and generic schema exports', () => { + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Value', + target: { kind: 'type-parameter', parameter: 'parameter' }, + arguments: [], + }])).toThrow('type parameter has no schema substitution') + + const generic = declaration('Generic', 'interface', { + typeParameters: [{ id: 'parameter', name: 'Value', const: false }], + }) + expect(() => emit([{ + id: 'root', + kind: 'reference', + name: 'Generic', + target: { kind: 'declaration', symbol: 'Generic' }, + arguments: [], + }], undefined, [generic])).toThrow('missing type argument Value') + + const genericRoot = declaration('Root', 'interface', { + typeParameters: [{ id: 'root:parameter', name: 'Value', const: false }], + }) + expect(() => emit([], genericRoot)).toThrow('generic schema exports require a concrete declaration') + }) + + it('rejects unsupported standard references and enums', () => { const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel expect(() => emit([intrinsic])) .toThrow('keyword intrinsic has no Zod projection') @@ -399,17 +568,6 @@ describe('SchemaEmitter unsupported projection matrix', () => { arguments: [], }])).toThrow('standard type Promise has no Zod projection') - const generic = declaration('Generic', 'interface', { - typeParameters: [{ id: 'parameter', name: 'Value', const: false }], - }) - expect(() => emit([{ - id: 'root', - kind: 'reference', - name: 'Generic', - target: { kind: 'declaration', symbol: 'Generic' }, - arguments: [], - }], undefined, [generic])).toThrow('generic declarations require a schema-factory projection') - const enumeration = declaration('Enumeration', 'enum', { enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }], }) @@ -481,6 +639,7 @@ describe('SchemaEmitter unsupported projection matrix', () => { }], objects: [], schemas: [], + invocations: [], }], } expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema')) @@ -513,6 +672,7 @@ describe('SchemaEmitter unsupported projection matrix', () => { }], objects: [], schemas: [], + invocations: [], }], } @@ -555,7 +715,33 @@ describe('SchemaEmitter unsupported projection matrix', () => { expect(artifact.dts).toContain("from '@fixture/schema/secondary'") }) - it.each(['method', 'getter', 'setter', 'call', 'construct', 'index'] as const)( + it('emits JSON index signatures as record schemas', async () => { + const root = declaration('Root', 'interface', { + members: [indexMember('key', 'value')], + }) + const schema = await loadSchema(emit([ + keyword('key', 'string'), + keyword('value', 'number'), + ], root)) + + expect(schema.safeParse({ one: 1, two: 2 }).success).toBe(true) + expect(schema.safeParse({ one: '1' }).success).toBe(false) + }) + + it('rejects more than one JSON index signature', () => { + const root = declaration('Root', 'interface', { + members: [indexMember('key', 'value'), indexMember('other-key', 'other-value')], + }) + + expect(() => emit([ + keyword('key', 'string'), + keyword('value', 'number'), + keyword('other-key', 'string'), + keyword('other-value', 'boolean'), + ], root)).toThrow('object type has more than one JSON index signature') + }) + + it.each(['method', 'getter', 'setter', 'call', 'construct'] as const)( 'rejects %s members on data-schema objects', (kind) => { expect(() => emit([ @@ -608,6 +794,10 @@ function property( } } +function signatureMember(kind: 'index'): SignatureMemberModel +function signatureMember( + kind: Exclude, +): MemberModel function signatureMember(kind: Exclude): MemberModel { return { ...documentation, @@ -626,6 +816,24 @@ function signatureMember(kind: Exclude): Member } } +function indexMember(key: string, value: string): SignatureMemberModel { + return { + ...signatureMember('index'), + signature: { + typeParameters: [], + parameters: [{ + name: 'key', + binding: 'identifier', + type: key, + optional: false, + rest: false, + receiver: false, + }], + returns: value, + }, + } +} + function declaration( name: string, kind: TypeDeclarationModel['kind'], @@ -684,6 +892,7 @@ function emit( symbol: 'Root', type: 'schema-reference', }], + invocations: [], }], } return new FaceModelEmitter(face).emit('@fixture/schema').js @@ -710,6 +919,7 @@ function schemaFace( symbol, type: 'root', }], + invocations: [], }], } } diff --git a/packages/typert/generator/tests/tools-catalog.spec.ts b/packages/typert/generator/tests/tools-catalog.spec.ts index 29193e66c1..95c1ab09de 100644 --- a/packages/typert/generator/tests/tools-catalog.spec.ts +++ b/packages/typert/generator/tests/tools-catalog.spec.ts @@ -62,7 +62,7 @@ describe('model-driven dsh-tools generation', () => { TYPE_API.find(type => type.name === 'ToolDefinition'), ) - dispose() + await dispose() expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined() }) }) diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 9b4057beee..655636aa79 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -12,6 +12,11 @@ const generated = vi.hoisted(() => vi.fn(() => [ exports: [], js: 'export const host = true\n', dts: 'export declare const host: true\n', + remote: { + js: 'export const remote = true\n', + dts: 'export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n', + dtsMap: '{"version":3}\n', + }, }, { package: '@deepseek-ai/dsh-tools', @@ -21,10 +26,30 @@ const generated = vi.hoisted(() => vi.fn(() => [ js: 'export const client = true\n', dts: 'export declare const client: true\n', }, + { + package: '@fixture/remote-only', + packageRoot: 'packages/remote-only', + face: 'host' as const, + exports: [], + js: 'export const local = true\n', + dts: 'export declare const local: true\n', + remote: { + js: 'export const remoteOnly = true\n', + dts: 'export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n', + dtsMap: '{"version":3}\n', + }, + }, +])) + +const discovered = vi.hoisted(() => vi.fn(() => [ + { package: '@deepseek-ai/dsh-tools', root: 'packages/core/tools', faces: ['host'] }, + { package: '@fixture/ignored', root: 'packages/ignored', faces: ['host'] }, + { package: '@fixture/remote-only', root: 'packages/remote-only', faces: ['host'] }, ])) vi.mock('../src/workspace.ts', () => ({ WorkspaceTypertGenerator: class { + discover = discovered generate = generated }, })) @@ -33,6 +58,7 @@ const { typertPlugin } = await import('../src/tsdown-plugin.ts') const roots: string[] = [] afterEach(() => { + discovered.mockClear() generated.mockClear() for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) @@ -80,9 +106,64 @@ describe('typertPlugin', () => { expect(readFileSync(join(packageLib, 'typert.host.d.ts'), 'utf8')).toBe('export declare const host: true\n') expect(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n') expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true) + expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')).toBe('export const remote = true\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8')) + .toBe('export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8')) + .toBe('{"version":3}\n') expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8')) .toBe('export const client = true\n') }) + + it('generates a package opted in only through its Remote export', async () => { + const root = await workspace() + const output = await packageOutput(root, 'remote-only', { + name: '@fixture/remote-only', + exports: { './remote': './lib/typert.remote-client.js' }, + }) + + typertPlugin().writeBundle({ dir: output }) + + const packageLib = join(root, 'packages', 'remote-only', 'lib') + expect(generated).toHaveBeenCalledOnce() + expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')) + .toBe('export const remoteOnly = true\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8')) + .toBe('export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n') + expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8')) + .toBe('{"version":3}\n') + }) + + it('emits every explicit workspace contributor once from a host-only prepass', async () => { + const root = await workspace() + const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' }) + await packageOutput(root, 'core/tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './typert': './lib/typert.host.js' }, + }) + await packageOutput(root, 'ignored', { name: '@fixture/ignored' }) + await packageOutput(root, 'remote-only', { + name: '@fixture/remote-only', + exports: { './remote': './lib/typert.remote-client.js' }, + }) + + const plugin = typertPlugin({ mode: 'workspace', faces: ['host'] }) + plugin.writeBundle({ dir: trigger }) + plugin.writeBundle({ dir: join(root, 'packages/core/tools/lib/dev') }) + + expect(discovered).toHaveBeenCalledOnce() + expect(discovered).toHaveBeenCalledWith(['host']) + expect(generated).toHaveBeenCalledOnce() + expect(generated).toHaveBeenCalledWith( + ['@deepseek-ai/dsh-tools', '@fixture/remote-only'], + ['host'], + ) + expect(readFileSync(join(root, 'packages/core/tools/lib/typert.host.js'), 'utf8')) + .toBe('export const host = true\n') + expect(readFileSync(join(root, 'packages/remote-only/lib/typert.remote-client.js'), 'utf8')) + .toBe('export const remoteOnly = true\n') + expect(existsSync(join(root, 'packages/ignored/lib/typert.host.js'))).toBe(false) + }) }) async function workspace(): Promise { diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 923c254d0f..ca37cd1bbe 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -201,6 +201,53 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { expect(batched).toEqual(direct) }) + it('discovers an explicitly keyed service implementation without a Context merge', () => { + const root = copyFixture('explicit-service-') + addExplicitServicePackage(root, 'service detached') + const analyzer = new WorkspaceAnalyzer({ root }) + + expect(analyzer.discoverPackages()).toContainEqual({ + package: '@fixture/explicit-service', + root: 'packages/explicit-service', + faces: ['host'], + }) + const model = new WorkspaceAnalyzer({ root, packages: ['@fixture/explicit-service'] }).analyze() + const service = model.faces[0]?.packages[0]?.services[0] + expect(service).toMatchObject({ key: 'detached', export: { name: 'DetachedService' } }) + }) + + it('prefers an explicitly keyed implementation over its protocol Context merge', () => { + const root = copyFixture('explicit-service-protocol-') + addExplicitServicePackage(root, 'service detached', true) + const model = new WorkspaceAnalyzer({ + root, + packages: ['@fixture/explicit-service'], + }).analyze() + const service = model.faces[0]?.packages[0]?.services[0] + + expect(service).toMatchObject({ + key: 'detached', + export: { name: 'DetachedService' }, + location: { file: 'packages/explicit-service/src/index.ts' }, + }) + }) + + it('rejects an explicit service implementation without one valid key', () => { + const missing = copyFixture('explicit-service-missing-') + addExplicitServicePackage(missing, 'service') + expect(() => new WorkspaceAnalyzer({ + root: missing, + packages: ['@fixture/explicit-service'], + }).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key') + + const invalid = copyFixture('explicit-service-invalid-') + addExplicitServicePackage(invalid, 'service bad/key') + expect(() => new WorkspaceAnalyzer({ + root: invalid, + packages: ['@fixture/explicit-service'], + }).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key') + }) + it('indexes authored top-level exports without promoting them to graph roots', () => { const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations() const agent = declarations.find(declaration => declaration.name === 'Agent') @@ -1178,6 +1225,57 @@ function addSameFacePackage(root: string, specifier: string, importedName: strin writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) } +function addExplicitServicePackage(root: string, annotation: string, withProtocol = false): void { + const packageRoot = join(root, 'packages/explicit-service') + mkdirSync(join(packageRoot, 'src'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: '@fixture/explicit-service', + private: true, + type: 'module', + exports: { + '.': { + types: './lib/types/index.d.ts', + default: './lib/index.js', + }, + }, + }, null, 2)) + writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', + compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, + include: ['src'], + }, null, 2)) + if (withProtocol) { + writeFileSync(join(packageRoot, 'src/types.ts'), [ + '/** Public detached Service protocol. */', + 'export interface DetachedProtocol {', + ' /** Report protocol readiness. */', + ' ready(): boolean', + '}', + "declare module 'cordis' {", + ' interface Context { detached: DetachedProtocol }', + '}', + '', + ].join('\n')) + } + writeFileSync(join(packageRoot, 'src/index.ts'), [ + "import { Service } from 'cordis'", + ...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []), + '/**', + ' * Service implementation discovered independently of its protocol package.', + ` * @typert ${annotation}`, + ' */', + 'export class DetachedService extends Service {', + ' /** Report readiness. */', + ' ready(): boolean { return true }', + '}', + '', + ].join('\n')) + const aggregatePath = join(root, 'tsconfig.host.json') + const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] } + aggregate.references.push({ path: './packages/explicit-service' }) + writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) +} + describe('FaceModelEmitter', { timeout: 60_000 }, () => { it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => { const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 9485a76f05..fee1098340 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -135,6 +135,11 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type requireMembers(pkgName, object.members, `object "${object.name as string}"`) requireTypes(pkgName, object.types, `object "${object.name as string}"`) } + if (manifest.invocations !== undefined) { + for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { + requireInvocation(pkgName, value) + } + } return manifest as unknown as TypertContribution } @@ -184,6 +189,88 @@ function requireTypes(pkgName: string, value: unknown, subject: string): void { } } +function requireInvocation(pkgName: string, value: unknown): void { + const invocation = requireObject(pkgName, value, 'invocation') + for (const key of ['id', 'service', 'namespace', 'method'] as const) { + requireString(pkgName, invocation, key, 'invocation') + } + const id = invocation.id as string + const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`) + if (receiver.kind === 'context') { + requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`) + requireString(pkgName, receiver, 'wire', `invocation "${id}" Context receiver`) + requireStrictCodec(pkgName, receiver.codec, `invocation "${id}" Context codec`) + } else if (receiver.kind !== 'direct') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" receiver kind must be "direct" or "context"`) + } + const wires = new Set() + const parameters = new Map>() + let lookupCount = 0 + for (const valueParameter of requireArray(pkgName, invocation.parameters, `invocation "${id}" parameters`)) { + const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`) + requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`) + requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`) + const wire = parameter.wire as string + if (wires.has(wire)) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`) + } + wires.add(wire) + if (parameter.source === 'lookup') { + lookupCount += 1 + requireString(pkgName, parameter, 'lookup', `invocation "${id}" lookup parameter`) + } else if (parameter.source === 'json') { + if (parameter.lookup !== undefined) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" JSON parameter declares a lookup`) + } + } else { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" parameter source must be "json" or "lookup"`) + } + parameters.set(wire, parameter) + requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) + } + if (invocation.scope !== undefined) { + if (receiver.kind !== 'direct') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`) + } + const scope = requireObject(pkgName, invocation.scope, `invocation "${id}" scope`) + requireString(pkgName, scope, 'context', `invocation "${id}" scope`) + requireString(pkgName, scope, 'wire', `invocation "${id}" scope`) + const parameter = parameters.get(scope.wire as string) + if (lookupCount !== 1 || parameter?.source !== 'lookup' || parameter.lookup !== scope.context) { + throw new Error( + `typert-loader: ${pkgName} invocation "${id}" scope wire "${scope.wire as string}" must select its only lookup parameter`, + ) + } + } + if (receiver.kind === 'context' && wires.has(receiver.wire as string)) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`) + } + requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`) + if (invocation.sourceLocation !== undefined) { + const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`) + requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`) + for (const key of ['line', 'column'] as const) { + if (!Number.isInteger(location[key]) || (location[key] as number) < 1) { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" sourceLocation.${key} must be a positive integer`) + } + } + } +} + +function requireStrictCodec(pkgName: string, value: unknown, subject: string): void { + const codec = requireObject(pkgName, value, subject) + if (codec.mode !== 'strict') { + throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`) + } + requireString(pkgName, codec, 'typeSymbol', subject) + if (typeof codec.schema !== 'object' + || codec.schema === null + || !('_zod' in codec.schema) + || typeof (codec.schema as { parse?: unknown }).parse !== 'function') { + throw new Error(`typert-loader: ${pkgName} ${subject} is not backed by a zod v4 schema`) + } +} + /** * Scan current Loader entries during activation, then follow entry mounts and * unmounts for this plugin's lifetime. @@ -202,7 +289,7 @@ export async function apply(ctx: Context, config: Config): Promise { const configured = new Set((config as ResolvedConfig).packages) // Registered contributions by entry name; the disposer withdraws the entry's registration. - const registered = new Map void>() + const registered = new Map Promise>() // In-flight import/register tasks by entry name. const pending = new Map>() // Artifact paths by package name. Negative verdicts (unresolvable specifier — @@ -279,7 +366,7 @@ export async function apply(ctx: Context, config: Config): Promise { const dispose = registered.get(entryName) if (dispose !== undefined) { registered.delete(entryName) - dispose() + return dispose() } return undefined } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 3b126f1e76..1e7e553605 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,6 +9,7 @@ import Loader from '@cordisjs/plugin-loader' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as typertLoader from '@deepseek-ai/dsh-typert-loader' import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' +import { z } from 'zod' let root: string | undefined let context: Context | undefined @@ -63,12 +65,45 @@ function typertSource(pkgName: string, entryName: string): string { ].join('\n') } +function invocationTypertSource(pkgName: string): string { + return [ + 'import { z } from \'zod\'', + 'const Text = z.string()', + 'export const TYPERT = {', + ` package: '${pkgName}',`, + ' face: \'host\',', + ' schemas: [],', + ' model: { services: [], events: [], objects: [] },', + ' invocations: [{', + ` id: '${pkgName}#goals/create',`, + ' service: \'goals\', namespace: \'goals\', method: \'create\',', + ' invocation: { kind: \'direct\' },', + ' parameters: [{', + ' name: \'request\', wire: \'request\', source: \'json\',', + ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`, + ' }],', + ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, + ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', + ' }],', + '}', + '', + ].join('\n') +} + /** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */ async function boot(): Promise { context = new Context() context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href await context.plugin(TypertRegistry) await context.plugin(Loader) + const fixtureRequire = createRequire(context.baseUrl) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + const module: unknown = await import(pathToFileURL(fixtureRequire.resolve(specifier)).href) + return module + }, + } as unknown as NonNullable // zod must be resolvable from the fixture packages; link the workspace copy. await mkdir(join(root as string, 'node_modules'), { recursive: true }) return context @@ -105,6 +140,33 @@ describe('typert loader', () => { expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined() }) + it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) + await linkZod(root) + await writePackage(root, '@fixture/invocation', { + typertSource: invocationTypertSource('@fixture/invocation'), + }) + const ctx = await boot() + + const fiber = mountTypertLoader(ctx, { packages: ['@fixture/invocation'] }) + await fiber + + const descriptor = ctx.typert.local.get('goals/create') + expect(descriptor).toMatchObject({ + id: '@fixture/invocation#goals/create', + invocation: { kind: 'direct' }, + parameters: [{ wire: 'request', source: 'json' }], + sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, + }) + expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') + if (descriptor?.parameters[0]?.codec.mode === 'strict') { + expect(descriptor.parameters[0].codec.schema.parse('request')).toBe('request') + } + + await fiber.dispose() + expect(ctx.typert.local.get('goals/create')).toBeUndefined() + }) + it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => { root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) await writePackage(root, '@fixture/plain') @@ -427,8 +489,156 @@ describe('validateTypertManifest', () => { model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] }, })).toThrow('object has a missing or empty exportName') }) + + it('validates strict invocation descriptors and accepts legacy manifests without them', () => { + const legacy = completeManifest(zodish) + expect(validateTypertManifest('pkg', legacy)).toBe(legacy) + + const descriptor = strictInvocation() + const manifest = { ...legacy, invocations: [descriptor] } + expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const scoped = { + ...descriptor, + scope: { context: 'agent', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: strictCodec('pkg#AgentId'), + }, ...descriptor.parameters], + } + expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations) + .toEqual([scoped]) + + expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} })) + .toThrow('TYPERT.invocations must be an array') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, invocation: { kind: 'future' } }], + })).toThrow('receiver kind must be "direct" or "context"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, result: { mode: 'src-json' } }], + })).toThrow('result codec must use a strict codec') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], + })).toThrow('result codec is not backed by a zod v4 schema') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'future' }], + }], + })).toThrow('parameter source must be "json" or "lookup"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], source: 'lookup' }], + }], + })).toThrow('lookup parameter has a missing or empty lookup') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }], + }], + })).toThrow('JSON parameter declares a lookup') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }], + }], + })).toThrow('repeats wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...descriptor, + invocation: { + kind: 'context', + context: 'agent', + wire: 'request', + codec: strictCodec('pkg#AgentId'), + }, + }], + })).toThrow('repeats Context wire field "request"') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: null }], + })).toThrow('scope must be an object') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { wire: 'agentId' } }], + })).toThrow('scope has a missing or empty context') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'agent' } }], + })).toThrow('scope has a missing or empty wire') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...scoped, + invocation: { + kind: 'context', + context: 'agent', + wire: 'scopeId', + codec: strictCodec('pkg#AgentId'), + }, + }], + })).toThrow('Context receiver cannot declare a direct scope projection') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ + ...scoped, + parameters: [...scoped.parameters, { + name: 'other', + wire: 'otherId', + source: 'lookup', + lookup: 'agent', + codec: strictCodec('pkg#AgentId'), + }], + }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }], + })).toThrow('must select its only lookup parameter') + expect(() => validateTypertManifest('pkg', { + ...legacy, + invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }], + })).toThrow('sourceLocation.line must be a positive integer') + }) }) +function strictCodec(typeSymbol: string) { + return { mode: 'strict', typeSymbol, schema: z.string() } +} + +function strictInvocation() { + return { + id: 'pkg#goals/create', + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: strictCodec('pkg#Request'), + }], + result: strictCodec('pkg#Result'), + sourceLocation: { file: 'src/index.ts', line: 1, column: 1 }, + } +} + function completeManifest(zodish: object) { const member = { name: 'member', signature: 'member(): void', kind: 'method' } const type = { name: 'Value', declaration: 'export interface Value {}' } diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index b543589dc6..e912808293 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -15,6 +15,10 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" @@ -22,14 +26,25 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, + "dshClient": { + "inject": [], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/typert/registry/src/client/index.ts b/packages/typert/registry/src/client/index.ts new file mode 100644 index 0000000000..e468e78999 --- /dev/null +++ b/packages/typert/registry/src/client/index.ts @@ -0,0 +1,15 @@ +/** Browser face of the shared TypeRT runtime registry. */ + +import type { Context } from 'cordis' +import { TypertRegistry } from '../service.ts' + +/** Required services: none; this is the Client reflection root. */ +export const inject: string[] = [] + +/** + * Install the same registry implementation used by the Host face. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new TypertRegistry(ctx) +} diff --git a/packages/typert/registry/src/index.ts b/packages/typert/registry/src/index.ts index 91a8383594..3619c02dff 100644 --- a/packages/typert/registry/src/index.ts +++ b/packages/typert/registry/src/index.ts @@ -1,12 +1,7 @@ -/** - * Runtime registry for generated Typert contributions. It owns live Zod - * instances and generated package reflection, but performs no TypeScript - * analysis or schema generation. - * @module @deepseek-ai/dsh-typert-registry - */ +/** Host entry for the shared TypeRT runtime registry. */ -import { Context, Service } from 'cordis' -import { z } from 'zod' +import type { z } from 'zod' +import type { TypeRTDisposer } from '@deepseek-ai/dsh-type-meta' import type { TypertContribution, TypertFace, @@ -16,204 +11,17 @@ import type { TypertSchemaRecord, } from './types.ts' -export type { - TypertContribution, - TypertDocTag, - TypertDocumentation, - TypertEventModel, - TypertFace, - TypertMemberModel, - TypertObjectModel, - TypertPackageFilter, - TypertPackageModel, - TypertPackageRecord, - TypertSchema, - TypertSchemaFilter, - TypertSchemaRecord, - TypertServiceModel, - TypertTypeModel, -} from './types.ts' +export { default, TypertRegistry, typertEndpoint, typertKey, typertPackageKey } from './service.ts' +export type * from './types.ts' -declare module 'cordis' { - interface Context { - typert: TypertRegistry +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTService { + register(contribution: TypertContribution): TypeRTDisposer + get(key: string): TypertSchemaRecord | undefined + resolve(key: string): TypertSchemaRecord + list(filter?: TypertSchemaFilter): TypertSchemaRecord[] + getPackage(packageName: string, face?: TypertFace): TypertPackageRecord | undefined + listPackages(filter?: TypertPackageFilter): TypertPackageRecord[] + toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema } } - -/** - * Compose the global key of one generated schema. - * @param packageName - contributing npm package. - * @param name - schema export name. - * @returns `#`. - */ -export function typertKey(packageName: string, name: string): string { - return `${packageName}#${name}` -} - -/** - * Compose the identity of one package-face model. - * @param packageName - contributing npm package. - * @param face - independently compiled face. - * @returns `#`. - */ -export function typertPackageKey(packageName: string, face: TypertFace): string { - return `${packageName}#${face}` -} - -/** - * Registry of generated schemas and package reflection. - * @typert service - */ -export class TypertRegistry extends Service { - private readonly schemas = new Map() - private readonly packages = new Map() - - constructor(ctx: Context) { - super(ctx, 'typert') - } - - /** - * Register one generated contribution atomically for the calling fiber. - * Duplicate package-face identities or schema keys reject the whole batch. - * @param contribution - generated schemas and package metadata. - * @returns the exact effect disposer that removes this contribution. - */ - register(contribution: TypertContribution): () => void { - const packageRecord = this.validatePackage(contribution) - const schemaRecords = this.validateSchemas(contribution) - const { schemas, packages } = this - const dispose = this.ctx.effect(function* () { - packages.set(packageRecord.key, packageRecord) - for (const record of schemaRecords) schemas.set(record.key, record) - yield () => { - packages.delete(packageRecord.key) - for (const record of schemaRecords) schemas.delete(record.key) - } - }, 'typert.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve Cordis disposer identity - return dispose - } - - /** - * Look up one schema by `#`. - * @param key - global schema key. - * @returns the live schema record, or `undefined` when absent. - */ - get(key: string): TypertSchemaRecord | undefined { - return this.schemas.get(key) - } - - /** - * Resolve one required schema. - * @param key - global schema key. - * @returns the live schema record. - * @throws when the key is malformed, the package face is absent, or the schema is not contributed. - */ - resolve(key: string): TypertSchemaRecord { - const record = this.schemas.get(key) - if (record !== undefined) return record - const hash = key.indexOf('#') - if (hash <= 0 || hash === key.length - 1) { - throw new Error(`typert: invalid schema key "${key}" — expected "#"`) - } - const packageName = key.slice(0, hash) - if ([...this.packages.values()].some(candidate => candidate.package === packageName)) { - throw new Error( - `typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`, - ) - } - throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`) - } - - /** - * Enumerate live schemas in registration order. - * @param filter - optional package and face restriction. - * @returns matching schema records. - */ - list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] { - return [...this.schemas.values()].filter(record => matches(record, filter)) - } - - /** - * Look up generated reflection for one package face. - * @param packageName - exact npm package name. - * @param face - face to query; defaults to the host runtime. - * @returns the live package record, or `undefined` when absent. - */ - getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined { - return this.packages.get(typertPackageKey(packageName, face)) - } - - /** - * Enumerate generated package reflection in registration order. - * @param filter - optional package and face restriction. - * @returns matching package records. - */ - listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] { - return [...this.packages.values()].filter(record => matches(record, filter)) - } - - /** - * Project a live Zod schema to JSON Schema without caching the result. - * @param key - global schema key. - * @param params - Zod projection parameters. - * @returns a fresh JSON Schema document. - */ - toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema { - return z.toJSONSchema(this.resolve(key).schema, params) - } - - private validatePackage(contribution: TypertContribution): TypertPackageRecord { - validateSegment('package name', contribution.package) - const face: unknown = contribution.face - if (face !== 'host' && face !== 'client') { - throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`) - } - const key = typertPackageKey(contribution.package, contribution.face) - if (this.packages.has(key)) { - throw new Error(`typert: package face "${key}" is already registered`) - } - return { - package: contribution.package, - face, - key, - model: contribution.model, - } - } - - private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] { - const records: TypertSchemaRecord[] = [] - const batch = new Set() - for (const schema of contribution.schemas) { - validateSegment('schema name', schema.name) - const key = typertKey(contribution.package, schema.name) - if (batch.has(key) || this.schemas.has(key)) { - throw new Error(`typert: schema "${key}" is already registered`) - } - batch.add(key) - records.push({ - ...schema, - package: contribution.package, - face: contribution.face, - key, - }) - } - return records - } -} - -function matches( - record: { readonly package: string; readonly face: TypertFace }, - filter: { readonly package?: string; readonly face?: TypertFace }, -): boolean { - return (filter.package === undefined || record.package === filter.package) - && (filter.face === undefined || record.face === filter.face) -} - -function validateSegment(subject: string, value: string): void { - if (value.length === 0 || value.includes('#')) { - throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`) - } -} - -export default TypertRegistry diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts new file mode 100644 index 0000000000..16160a3860 --- /dev/null +++ b/packages/typert/registry/src/service.ts @@ -0,0 +1,584 @@ +/** + * Runtime registry for generated TypeRT reflection, Remote invocations, and + * dependency-inverted lookup/Context providers. It performs no TypeScript + * analysis or schema generation. + * @module @deepseek-ai/dsh-typert-registry + */ + +import { Context, Service } from 'cordis' +import { z } from 'zod' +import type { + InvocationDescriptor, + TypeRTClientContextBinder, + TypeRTContextMap, + TypeRTContextRegistry, + TypeRTContextWire, + TypeRTDisposer, + TypeRTHostContextProvider, + TypeRTLocalRegistry, + TypeRTLookupHost, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupRegistry, + TypeRTLookupWire, + TypeRTRemoteContribution, + TypeRTRemoteRegistry, + TypeRTRegistryChange, + TypeRTRegistryListener, + TypeRTService, +} from '@deepseek-ai/dsh-type-meta' +import type { + TypertContribution, + TypertFace, + TypertPackageFilter, + TypertPackageRecord, + TypertSchemaFilter, + TypertSchemaRecord, +} from './types.ts' + +/** + * Compose the global key of one generated schema. + * @param packageName - contributing npm package. + * @param name - schema export name. + * @returns `#`. + */ +export function typertKey(packageName: string, name: string): string { + return `${packageName}#${name}` +} + +/** + * Compose the identity of one package-face model. + * @param packageName - contributing npm package. + * @param face - independently compiled face. + * @returns `#`. + */ +export function typertPackageKey(packageName: string, face: TypertFace): string { + return `${packageName}#${face}` +} + +/** + * Compose the endpoint key used by local and Remote invocation registries. + * @param descriptor - invocation whose namespace and method form the endpoint. + * @returns `/`. + */ +export function typertEndpoint(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}` +} + +interface DescriptorEntry { + readonly descriptor: InvocationDescriptor + readonly owner: object +} + +interface ProviderEntry { + readonly provider: Provider + readonly owner: object +} + +type ReportObserverError = (change: TypeRTRegistryChange, error: unknown) => void + +class ChangeSource { + private readonly listeners = new Set() + + constructor(private readonly report: ReportObserverError) {} + + subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer { + const { listeners } = this + return ctx.effect(function* () { + listeners.add(listener) + yield () => { listeners.delete(listener) } + }, 'typert registry subscription') + } + + emit(change: TypeRTRegistryChange): void { + for (const listener of [...this.listeners]) { + try { + listener(change) + } catch (error) { + this.report(change, error) + } + } + } +} + +class DescriptorStore { + private readonly entries = new Map() + private readonly ids = new Map() + private readonly history = new Set() + private readonly changes: ChangeSource + + constructor( + private readonly kind: 'local' | 'remote', + report: ReportObserverError, + ) { + this.changes = new ChangeSource(report) + } + + validate(descriptors: readonly InvocationDescriptor[]): void { + const endpoints = new Set() + const ids = new Set() + for (const descriptor of descriptors) { + validateInvocation(descriptor) + const endpoint = typertEndpoint(descriptor) + if (endpoints.has(endpoint) || this.entries.has(endpoint)) { + throw new Error(`typert: ${this.kind} endpoint "${endpoint}" is already registered`) + } + if (ids.has(descriptor.id) || this.ids.has(descriptor.id)) { + throw new Error(`typert: ${this.kind} invocation id "${descriptor.id}" is already registered`) + } + endpoints.add(endpoint) + ids.add(descriptor.id) + } + } + + commit(owner: object, descriptors: readonly InvocationDescriptor[]): void { + for (const descriptor of descriptors) { + const entry = { descriptor, owner } + const endpoint = typertEndpoint(descriptor) + this.entries.set(endpoint, entry) + this.ids.set(descriptor.id, entry) + this.history.add(endpoint) + } + for (const descriptor of descriptors) { + this.changes.emit({ kind: this.kind, key: typertEndpoint(descriptor) }) + } + } + + withdraw(owner: object, descriptors: readonly InvocationDescriptor[]): void { + const removed: string[] = [] + for (const descriptor of descriptors) { + const endpoint = typertEndpoint(descriptor) + const entry = this.entries.get(endpoint) + if (entry?.owner !== owner) continue + this.entries.delete(endpoint) + if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id) + removed.push(endpoint) + } + for (const endpoint of removed) this.changes.emit({ kind: this.kind, key: endpoint }) + } + + get(endpoint: string): InvocationDescriptor | undefined { + return this.entries.get(endpoint)?.descriptor + } + + hasSeen(endpoint: string): boolean { + return this.history.has(endpoint) + } + + list(): readonly InvocationDescriptor[] { + return [...this.entries.values()].map(entry => entry.descriptor) + } + + subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer { + return this.changes.subscribe(ctx, listener) + } +} + +class RemoteStore { + private readonly packages = new Map() + + constructor(private readonly descriptors: DescriptorStore) {} + + view(ctx: Context): TypeRTRemoteRegistry { + return { + register: contribution => this.register(ctx, contribution), + get: endpoint => this.descriptors.get(endpoint), + list: () => this.descriptors.list(), + subscribe: listener => this.descriptors.subscribe(ctx, listener), + } + } + + private register(ctx: Context, contribution: TypeRTRemoteContribution): TypeRTDisposer { + validateSegment('Remote package name', contribution.package) + if (this.packages.has(contribution.package)) { + throw new Error(`typert: Remote package "${contribution.package}" is already registered`) + } + this.descriptors.validate(contribution.descriptors) + const owner = {} + const { packages, descriptors } = this + return ctx.effect(function* () { + packages.set(contribution.package, owner) + descriptors.commit(owner, contribution.descriptors) + yield () => { + if (packages.get(contribution.package) === owner) packages.delete(contribution.package) + descriptors.withdraw(owner, contribution.descriptors) + } + }, `typert.remotes.register(${JSON.stringify(contribution.package)})`) + } +} + +class LookupStore { + private readonly providers = new Map>() + private readonly changes: ChangeSource + + constructor(report: ReportObserverError) { + this.changes = new ChangeSource(report) + } + + view(ctx: Context): TypeRTLookupRegistry { + return { + register: >( + key: K, + provider: TypeRTLookupProvider< + TypeRTLookupHost, + TypeRTLookupWire + >, + ) => this.register(ctx, key, provider), + get: key => this.providers.get(key)?.provider, + keys: () => [...this.providers.keys()], + subscribe: listener => this.changes.subscribe(ctx, listener), + } + } + + private register(ctx: Context, key: string, provider: TypeRTLookupProvider): TypeRTDisposer { + validateSegment('lookup key', key) + validateSegment('lookup parameter', provider.parameter) + validateWireName('lookup wire field', provider.wire) + validateNonempty('lookup Host type symbol', provider.hostTypeSymbol) + validateNonempty('lookup wire type symbol', provider.wireTypeSymbol) + if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`) + const owner = {} + const entry: ProviderEntry = { provider, owner } + const { providers, changes } = this + return ctx.effect(function* () { + providers.set(key, entry) + changes.emit({ kind: 'lookup', key }) + yield () => { + if (providers.get(key) !== entry) return + providers.delete(key) + changes.emit({ kind: 'lookup', key }) + } + }, `typert.lookups.register(${JSON.stringify(key)})`) + } +} + +class ContextStore { + private readonly hosts = new Map>() + private readonly clients = new Map>() + private readonly changes: ChangeSource + + constructor(report: ReportObserverError) { + this.changes = new ChangeSource(report) + } + + view(ctx: Context): TypeRTContextRegistry { + return { + registerHost: >( + key: K, + provider: TypeRTHostContextProvider>, + ) => this.registerHost(ctx, key, provider), + registerClient: >( + key: K, + binder: TypeRTClientContextBinder>, + ) => this.registerClient(ctx, key, binder), + getHost: key => this.hosts.get(key)?.provider, + getClient: key => this.clients.get(key)?.provider, + subscribe: listener => this.changes.subscribe(ctx, listener), + } + } + + private registerHost(ctx: Context, key: string, provider: TypeRTHostContextProvider): TypeRTDisposer { + validateSegment('Context key', key) + validateWireName('Context wire field', provider.wire) + validateNonempty('Context wire type symbol', provider.wireTypeSymbol) + return this.registerProvider(ctx, this.hosts, 'host-context', key, provider) + } + + private registerClient(ctx: Context, key: string, binder: TypeRTClientContextBinder): TypeRTDisposer { + validateSegment('Context key', key) + return this.registerProvider(ctx, this.clients, 'client-context', key, binder) + } + + private registerProvider( + ctx: Context, + table: Map>, + kind: 'host-context' | 'client-context', + key: string, + provider: Provider, + ): TypeRTDisposer { + if (table.has(key)) throw new Error(`typert: ${kind} provider "${key}" is already registered`) + const entry: ProviderEntry = { provider, owner: {} } + const { changes } = this + return ctx.effect(function* () { + table.set(key, entry) + changes.emit({ kind, key }) + yield () => { + if (table.get(key) !== entry) return + table.delete(key) + changes.emit({ kind, key }) + } + }, `typert.contexts.register(${JSON.stringify(key)})`) + } +} + +/** + * Registry of generated schemas, package reflection, invocations, and Remote + * dependency providers. + * @typert service typert + */ +export class TypertRegistry extends Service implements TypeRTService { + private readonly schemas = new Map() + private readonly packages = new Map() + private readonly localStore: DescriptorStore + private readonly remoteStore: RemoteStore + private readonly lookupStore: LookupStore + private readonly contextStore: ContextStore + + constructor(ctx: Context) { + super(ctx, 'typert') + const report: ReportObserverError = (change, error) => { + ctx.logger.warn(`typert: ${change.kind} observer for "${change.key}" failed`) + ctx.logger.warn(error) + } + this.localStore = new DescriptorStore('local', report) + this.remoteStore = new RemoteStore(new DescriptorStore('remote', report)) + this.lookupStore = new LookupStore(report) + this.contextStore = new ContextStore(report) + } + + /** Current-environment invocation definitions. */ + get local(): TypeRTLocalRegistry { + const ctx = this.ctx + return { + get: endpoint => this.localStore.get(endpoint), + hasSeen: endpoint => this.localStore.hasSeen(endpoint), + list: () => this.localStore.list(), + subscribe: listener => this.localStore.subscribe(ctx, listener), + } + } + + /** Consumer-selected Remote definitions. */ + get remotes(): TypeRTRemoteRegistry { + return this.remoteStore.view(this.ctx) + } + + /** Host object lookup providers. */ + get lookups(): TypeRTLookupRegistry { + return this.lookupStore.view(this.ctx) + } + + /** Host Context providers and Client Context binders. */ + get contexts(): TypeRTContextRegistry { + return this.contextStore.view(this.ctx) + } + + /** + * Register one generated contribution atomically for the calling fiber. + * Duplicate package-face identities, schemas, invocation ids, or endpoints + * reject the whole batch. + * @param contribution - generated schemas, reflection, and Host invocations. + * @returns the exact effect disposer that removes this contribution. + */ + register(contribution: TypertContribution): TypeRTDisposer { + const packageRecord = this.validatePackage(contribution) + const schemaRecords = this.validateSchemas(contribution) + const invocations = contribution.invocations ?? [] + this.localStore.validate(invocations) + const owner = {} + const { schemas, packages, localStore } = this + return this.ctx.effect(function* () { + packages.set(packageRecord.key, packageRecord) + for (const record of schemaRecords) schemas.set(record.key, record) + localStore.commit(owner, invocations) + yield () => { + if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key) + for (const record of schemaRecords) { + if (schemas.get(record.key) === record) schemas.delete(record.key) + } + localStore.withdraw(owner, invocations) + } + }, 'typert.register()') + } + + /** + * Look up one schema by `#`. + * @param key - global schema key. + * @returns the live schema record, or `undefined` when absent. + */ + get(key: string): TypertSchemaRecord | undefined { + return this.schemas.get(key) + } + + /** + * Resolve one required schema. + * @param key - global schema key. + * @returns the live schema record. + * @throws when the key is malformed, the package face is absent, or the schema is not contributed. + */ + resolve(key: string): TypertSchemaRecord { + const record = this.schemas.get(key) + if (record !== undefined) return record + const hash = key.indexOf('#') + if (hash <= 0 || hash === key.length - 1) { + throw new Error(`typert: invalid schema key "${key}" — expected "#"`) + } + const packageName = key.slice(0, hash) + if ([...this.packages.values()].some(candidate => candidate.package === packageName)) { + throw new Error( + `typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`, + ) + } + throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`) + } + + /** + * Enumerate live schemas in registration order. + * @param filter - optional package and face restriction. + * @returns matching schema records. + */ + list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] { + return [...this.schemas.values()].filter(record => matches(record, filter)) + } + + /** + * Look up generated reflection for one package face. + * @param packageName - exact npm package name. + * @param face - face to query; defaults to the host runtime. + * @returns the live package record, or `undefined` when absent. + */ + getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined { + return this.packages.get(typertPackageKey(packageName, face)) + } + + /** + * Enumerate generated package reflection in registration order. + * @param filter - optional package and face restriction. + * @returns matching package records. + */ + listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] { + return [...this.packages.values()].filter(record => matches(record, filter)) + } + + /** + * Project a live Zod schema to JSON Schema without caching the result. + * @param key - global schema key. + * @param params - Zod projection parameters. + * @returns a fresh JSON Schema document. + */ + toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema { + return z.toJSONSchema(this.resolve(key).schema, params) + } + + private validatePackage(contribution: TypertContribution): TypertPackageRecord { + validateSegment('package name', contribution.package) + const face: unknown = contribution.face + if (face !== 'host' && face !== 'client') { + throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`) + } + const key = typertPackageKey(contribution.package, contribution.face) + if (this.packages.has(key)) { + throw new Error(`typert: package face "${key}" is already registered`) + } + return { + package: contribution.package, + face, + key, + model: contribution.model, + } + } + + private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] { + const records: TypertSchemaRecord[] = [] + const batch = new Set() + for (const schema of contribution.schemas) { + validateSegment('schema name', schema.name) + const key = typertKey(contribution.package, schema.name) + if (batch.has(key) || this.schemas.has(key)) { + throw new Error(`typert: schema "${key}" is already registered`) + } + batch.add(key) + records.push({ + ...schema, + package: contribution.package, + face: contribution.face, + key, + }) + } + return records + } +} + +function matches( + record: { readonly package: string; readonly face: TypertFace }, + filter: { readonly package?: string; readonly face?: TypertFace }, +): boolean { + return (filter.package === undefined || record.package === filter.package) + && (filter.face === undefined || record.face === filter.face) +} + +function validateInvocation(descriptor: InvocationDescriptor): void { + validateNonempty('invocation id', descriptor.id) + validateSegment('invocation service key', descriptor.service) + validateWireName('invocation namespace', descriptor.namespace) + validateWireName('invocation method', descriptor.method) + if (descriptor.implementation !== undefined) { + validateWireName('invocation implementation method', descriptor.implementation) + } + validateCodec(descriptor.result, `${descriptor.id} result`) + const wires = new Set() + for (const parameter of descriptor.parameters) { + validateWireName('parameter name', parameter.name) + validateWireName('parameter wire field', parameter.wire) + if (wires.has(parameter.wire)) { + throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${parameter.wire}"`) + } + wires.add(parameter.wire) + if (parameter.source === 'lookup') { + if (parameter.lookup === undefined) { + throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`) + } + validateSegment('lookup key', parameter.lookup) + } else if (parameter.lookup !== undefined) { + throw new Error(`typert: invocation "${descriptor.id}" JSON parameter "${parameter.name}" declares a lookup key`) + } + validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) + } + if (descriptor.scope !== undefined) { + if (descriptor.invocation.kind !== 'direct') { + throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) + } + validateSegment('scope Context key', descriptor.scope.context) + validateWireName('scope wire field', descriptor.scope.wire) + const lookups = descriptor.parameters.filter(candidate => candidate.source === 'lookup') + const parameter = lookups.length === 1 ? lookups[0] : undefined + if (parameter === undefined || parameter.wire !== descriptor.scope.wire + || parameter.lookup !== descriptor.scope.context) { + throw new Error( + `typert: invocation "${descriptor.id}" scope wire "${descriptor.scope.wire}" must select its only lookup parameter`, + ) + } + } + if (descriptor.invocation.kind === 'context') { + validateSegment('Context key', descriptor.invocation.context) + validateWireName('Context wire field', descriptor.invocation.wire) + if (wires.has(descriptor.invocation.wire)) { + throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${descriptor.invocation.wire}"`) + } + validateCodec(descriptor.invocation.codec, `${descriptor.id} Context`) + } +} + +function validateCodec(codec: InvocationDescriptor['result'], subject: string): void { + if (codec.mode === 'src-json') return + validateNonempty(`${subject} type symbol`, codec.typeSymbol) + if (typeof codec.schema.parse !== 'function') { + throw new Error(`typert: ${subject} strict codec has no parse() method`) + } +} + +function validateWireName(subject: string, value: string): void { + validateSegment(subject, value) + if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`) +} + +function validateSegment(subject: string, value: string): void { + if (value.length === 0 || value.includes('#')) { + throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`) + } +} + +function validateNonempty(subject: string, value: string): void { + if (value.length === 0) throw new Error(`typert: invalid ${subject} — must be nonempty`) +} + +export default TypertRegistry diff --git a/packages/typert/registry/src/types.ts b/packages/typert/registry/src/types.ts index 2fb29f5024..6ba0e0f1f2 100644 --- a/packages/typert/registry/src/types.ts +++ b/packages/typert/registry/src/types.ts @@ -5,6 +5,7 @@ */ import type { z } from 'zod' +import type { InvocationDescriptor } from '@deepseek-ai/dsh-type-meta' /** Independently compiled side that produced a contribution. */ export type TypertFace = 'host' | 'client' @@ -82,6 +83,13 @@ export interface TypertContribution { readonly face: TypertFace readonly schemas: readonly TypertSchema[] readonly model: TypertPackageModel + /** Host invocation definitions; absent on artifacts generated before Remote support. */ + readonly invocations?: readonly InvocationDescriptor[] +} + +/** Generated Host contribution with strict Remote invocation definitions. */ +export interface TypertLocalContribution extends TypertContribution { + readonly invocations: readonly InvocationDescriptor[] } /** A live schema plus its contribution identity. */ diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 06eb9c107e..a98f99f912 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -2,10 +2,27 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { z } from 'zod' import TypertRegistry, { + typertEndpoint, typertKey, typertPackageKey, type TypertContribution, } from '@deepseek-ai/dsh-typert-registry' +import type { + InvocationDescriptor, + TypeRTContext, + TypeRTLookup, + TypeRTRemoteContribution, +} from '@deepseek-ai/dsh-type-meta' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTLookupMap { + fixture: TypeRTLookup<{ readonly id: string }, string> + } + + interface TypeRTContextMap { + registryFixture: TypeRTContext + } +} async function makeCtx(): Promise { const ctx = new Context() @@ -42,6 +59,42 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })): } } +function invocation(id = '@fixture/remote#goals/create'): InvocationDescriptor { + return { + id, + service: 'goals', + namespace: 'goals', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'src-json' }, + }], + result: { mode: 'src-json' }, + } +} + +function scopedInvocation(): InvocationDescriptor { + return { + ...invocation('@fixture/remote#goals/create-scoped'), + scope: { context: 'fixture', wire: 'agentId' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'src-json' }, + }, { + name: 'request', + wire: 'request', + source: 'json', + codec: { mode: 'src-json' }, + }], + } +} + describe('TypertRegistry', () => { it('registers and queries generated schemas separately from package reflection', async () => { const ctx = await makeCtx() @@ -69,7 +122,7 @@ describe('TypertRegistry', () => { const dispose = ctx.typert.register(toolsContribution()) expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined() - dispose() + await dispose() expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined() expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() @@ -145,4 +198,133 @@ describe('TypertRegistry', () => { expect(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } }) expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected) }) + + it('registers local invocations atomically with generated reflection', async () => { + const ctx = await makeCtx() + const descriptor = invocation() + const contribution = { ...toolsContribution(), invocations: [descriptor] } + const changes: string[] = [] + ctx.typert.local.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) }) + + expect(ctx.typert.local.hasSeen('goals/create')).toBe(false) + const dispose = ctx.typert.register(contribution) + + expect(typertEndpoint(descriptor)).toBe('goals/create') + expect(ctx.typert.local.get('goals/create')).toBe(descriptor) + expect(ctx.typert.local.hasSeen('goals/create')).toBe(true) + expect(ctx.typert.local.list()).toEqual([descriptor]) + expect(changes).toEqual(['local:goals/create']) + + await dispose() + expect(ctx.typert.local.list()).toEqual([]) + expect(ctx.typert.local.hasSeen('goals/create')).toBe(true) + expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined() + expect(changes).toEqual(['local:goals/create', 'local:goals/create']) + }) + + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { + const ctx = await makeCtx() + const descriptor = invocation() + const contribution: TypeRTRemoteContribution = { + package: '@fixture/remote', + descriptors: [descriptor], + } + const changes: string[] = [] + ctx.typert.remotes.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) }) + const fiber = ctx.plugin(Object.assign( + (child: Context) => { child.typert.remotes.register(contribution) }, + { inject: ['typert'] }, + )) + await fiber + + expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor) + expect(() => ctx.typert.remotes.register(contribution)).toThrow('Remote package') + + await fiber.dispose() + expect(ctx.typert.remotes.list()).toEqual([]) + expect(changes).toEqual(['remote:goals/create', 'remote:goals/create']) + }) + + it('accepts only a direct scope selecting its unique lookup parameter', async () => { + const ctx = await makeCtx() + const descriptor = scopedInvocation() + const dispose = ctx.typert.remotes.register({ package: '@fixture/scoped', descriptors: [descriptor] }) + expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor) + await dispose() + + const cases: readonly [InvocationDescriptor, string][] = [ + [{ + ...descriptor, + invocation: { + kind: 'context', + context: 'fixture', + wire: 'scopeId', + codec: { mode: 'src-json' }, + }, + }, 'Context receiver cannot declare a direct scope projection'], + [{ ...descriptor, scope: { context: 'fixture', wire: 'missingId' } }, 'must select its only lookup parameter'], + [{ + ...descriptor, + parameters: [...descriptor.parameters, { + name: 'other', + wire: 'otherId', + source: 'lookup', + lookup: 'fixture', + codec: { mode: 'src-json' }, + }], + }, 'must select its only lookup parameter'], + [{ ...descriptor, scope: { context: 'other', wire: 'agentId' } }, 'must select its only lookup parameter'], + ] + for (const [index, [candidate, message]] of cases.entries()) { + expect(() => ctx.typert.remotes.register({ + package: `@fixture/rejected-${String(index)}`, + descriptors: [candidate], + })).toThrow(message) + } + expect(ctx.typert.remotes.list()).toEqual([]) + }) + + it('registers lookup and Context providers without domain branches', async () => { + const ctx = await makeCtx() + const object = { id: 'agent-1' } + const scoped = ctx.extend() + const disposeLookup = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === object.id ? object : undefined, + }) + const disposeHost = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === object.id ? scoped : undefined, + }) + const disposeClient = ctx.typert.contexts.registerClient('registryFixture', { + identity: candidate => candidate === scoped ? object.id : undefined, + }) + + expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) + expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) + expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') + + await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) + expect(ctx.typert.lookups.keys()).toEqual([]) + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() + }) + + it('contains change-listener failures and still notifies later listeners', async () => { + const ctx = await makeCtx() + const warnings: unknown[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(message) }) as typeof ctx.logger.warn + let observed = 0 + ctx.typert.remotes.subscribe(() => { throw new Error('observer failed') }) + ctx.typert.remotes.subscribe(() => { observed += 1 }) + + ctx.typert.remotes.register({ package: '@fixture/remote', descriptors: [invocation()] }) + + expect(observed).toBe(1) + expect(warnings.map(String)).toContain('Error: observer failed') + }) }) diff --git a/packages/typert/registry/tsconfig.json b/packages/typert/registry/tsconfig.json index 9966c8ca8a..311dfa4b6d 100644 --- a/packages/typert/registry/tsconfig.json +++ b/packages/typert/registry/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../type-meta" } ] } diff --git a/packages/typert/registry/tsdown.config.ts b/packages/typert/registry/tsdown.config.ts index 144513225b..e494104c4d 100644 --- a/packages/typert/registry/tsdown.config.ts +++ b/packages/typert/registry/tsdown.config.ts @@ -1,25 +1,3 @@ -import { defineConfig } from 'tsdown' +import { clientBundle } from '../../client/tsdown.client.ts' -/** Build the registry and its invariant companion as independent bundles. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, - { - entry: ['lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - }, -]) +export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml new file mode 100644 index 0000000000..90d93152b7 --- /dev/null +++ b/packages/typert/type-meta/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/typert/type-meta/README.md +README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43 +README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md new file mode 100644 index 0000000000..9dd8dadd07 --- /dev/null +++ b/packages/typert/type-meta/README.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-type-meta + +English | [中文](README.zh.md) + +Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service. + +## Remote declarations + +- `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. +- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. +- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. +- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. + +Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. + +## TypeRT protocol + +Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. + +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. + +## Model Experience + +None, as this protocol package declares application reflection and registers no model surface. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + +- Decorator markers contain only the method name and direct or Context invocation mode. Parameter, result, lookup, and schema reflection require the TypeRT build pipeline. +- Remote decorators accept only public, non-static instance methods with string names. SRC execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures. diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md new file mode 100644 index 0000000000..5716f56d98 --- /dev/null +++ b/packages/typert/type-meta/README.zh.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-type-meta + +[English](README.md) | 中文 + +该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。 + +## Remote 声明 + +- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 +- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 +- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 +- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 + +装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 + +## TypeRT 协议 + +业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 + +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 + +## 模型体验 + +无,因为该协议包声明应用反射,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与延期工作 + +- 装饰器标记仅包含方法名,以及直接调用或 Context 调用模式。参数、结果、查找和 schema 反射需要 TypeRT 构建流水线。 +- Remote 装饰器只接受具有字符串名称的公开、非静态实例方法。SRC 执行无法表示重载签名,以及包含解构参数、默认参数或剩余参数的方法签名。 diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json new file mode 100644 index 0000000000..2ffcd6c5ed --- /dev/null +++ b/packages/typert/type-meta/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-type-meta", + "description": "Compiler-independent Remote metadata and TypeRT provider protocols", + "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" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.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/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts new file mode 100644 index 0000000000..1e79bb2e55 --- /dev/null +++ b/packages/typert/type-meta/src/index.ts @@ -0,0 +1,223 @@ +/** + * Remote decorators and explicit Gateway bindings backed only by private + * module state. Strict reflection remains a TypeRT compiler responsibility. + * @module @deepseek-ai/dsh-type-meta + */ + +import type { TypeRTContextMap } from './types.ts' + +export type { + InvocationDescriptor, + InvocationParameterDescriptor, + InvocationSourceLocation, + TypeRTClientContextBinder, + TypeRTCodec, + TypeRTContext, + TypeRTContextMap, + TypeRTContextRegistry, + TypeRTContextWire, + TypeRTDisposer, + TypeRTHostContextProvider, + TypeRTLocalRegistry, + TypeRTLookup, + TypeRTLookupHost, + TypeRTLookupMap, + TypeRTLookupProvider, + TypeRTLookupRegistry, + TypeRTLookupWire, + TypeRTRemoteContextApi, + TypeRTRemoteContextMap, + TypeRTRemoteContextNamespace, + TypeRTRemoteContribution, + TypeRTRemoteMap, + TypeRTRemoteNamespace, + TypeRTRemoteNamespaceMap, + TypeRTRemoteRegistry, + TypeRTRegistryChange, + TypeRTRegistryListener, + TypeRTSchema, + TypeRTService, +} from './types.ts' + +/** Options for an explicit Service-to-Gateway binding. */ +export interface TypeRTGatewayBindingOptions { + /** Wire namespace; defaults to the Cordis service key. */ + readonly namespace?: string +} + +/** Visible declaration that one Service participates in TypeRT Gateway export. */ +export interface TypeRTGatewayBinding { + readonly service: Service + readonly serviceKey: string + readonly namespace: string +} + +/** Invocation mode recorded by a Remote method decorator. */ +export type RemoteInvocationMarker = + | { readonly kind: 'direct' } + | { readonly kind: 'context'; readonly context: string } + +/** One decorator marker discovered for a live Service instance. */ +export interface RemoteMethodMarker { + /** Public instance method carrying the implementation. */ + readonly method: string + /** Endpoint method when it differs from the implementation member. */ + readonly exportName?: string + readonly invocation: RemoteInvocationMarker +} + +type RemoteMethodDecorator = ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, +) => void + +interface RemoteInitializerContext { + readonly private: boolean + readonly static: boolean + readonly name: string | symbol + addInitializer(initializer: (this: This) => void): void +} + +interface StoredRemoteMethodMarker { + readonly exportName?: string + readonly invocation: RemoteInvocationMarker +} + +const markers = new WeakMap>() + +/** + * Bind one visible Service field to a Cordis key and Remote namespace. + * @param service - owning Service instance, normally `this`. + * @param serviceKey - exact Cordis service key. + * @param options - optional distinct wire namespace. + * @returns a frozen, inspectable binding with no compiler-injected metadata. + */ +export function bindTypeRTGateway( + service: Service, + serviceKey: string, + options: TypeRTGatewayBindingOptions = {}, +): TypeRTGatewayBinding { + validateName('service key', serviceKey) + const namespace = options.namespace ?? serviceKey + validateName('namespace', namespace) + return Object.freeze({ service, serviceKey, namespace }) +} + +/** + * Mark one public instance method as a direct Remote invocation. + * @param _method - decorated method; retained only by the class itself. + * @param context - standard decorator context used to schedule private marking. + */ +export function Remote( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, +): void +/** + * Mark one public instance method under a distinct exported method name. + * @param exportName - Remote endpoint method, without a namespace or slash. + * @returns a standard method decorator. + */ +export function Remote(exportName: string): RemoteMethodDecorator +export function Remote( + methodOrExportName: string | ((this: This, ...args: Args) => Result), + context?: ClassMethodDecoratorContext Result>, +): void | RemoteMethodDecorator { + if (typeof methodOrExportName === 'string') { + validateName('Remote export name', methodOrExportName) + return function ( + _method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult, + decoratorContext: ClassMethodDecoratorContext< + DecoratorThis, + (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult + >, + ): void { + addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName) + } + } + if (context === undefined) throw new TypeError('type-meta: Remote decorator context is missing') + addMarkerInitializer(context, { kind: 'direct' }) +} + +/** + * Create a decorator for a method resolved from one scoped Remote Context. + * @param key - merge-declared Context key. + * @param exportName - optional Remote export name; defaults to the method name. + * @returns a standard method decorator that records only private module state. + */ +export function RemoteContext( + key: Extract, + exportName?: string, +): RemoteMethodDecorator { + validateName('Context key', key) + if (exportName !== undefined) validateName('Remote export name', exportName) + return function ( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void { + addMarkerInitializer(context, { kind: 'context', context: key }, exportName) + } +} + +/** + * Read Remote markers attached to a live Service by decorator initializers. + * The returned snapshot cannot mutate the private marker table. + * @param service - live Service instance. + * @returns markers in class declaration order. + */ +export function remoteMethods(service: object): readonly RemoteMethodMarker[] { + const prototype = Object.getPrototypeOf(service) as object | null + if (prototype === null) return [] + return [...(markers.get(prototype) ?? [])].map(([method, marker]) => ({ method, ...marker })) +} + +function addMarkerInitializer( + context: RemoteInitializerContext, + invocation: RemoteInvocationMarker, + exportName?: string, +): void { + if (context.private || context.static || typeof context.name !== 'string') { + throw new TypeError('type-meta: Remote decorators require a public instance method with a string name') + } + const method = context.name + context.addInitializer(function (this: This) { + const prototype = Object.getPrototypeOf(this) as object | null + if (prototype === null) { + throw new TypeError(`type-meta: cannot mark Remote method "${method}" on an object without a prototype`) + } + mark(prototype, method, invocation, exportName) + }) +} + +function mark( + prototype: object, + method: string, + invocation: RemoteInvocationMarker, + exportName?: string, +): void { + let table = markers.get(prototype) + if (table === undefined) { + table = new Map() + markers.set(prototype, table) + } + const marker: StoredRemoteMethodMarker = { + ...(exportName === undefined || exportName === method ? {} : { exportName }), + invocation: Object.freeze(invocation), + } + const current = table.get(method) + if (current !== undefined) { + if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return + throw new Error(`type-meta: Remote method "${method}" has conflicting invocation markers`) + } + table.set(method, Object.freeze(marker)) +} + +function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMarker): boolean { + return left.kind === right.kind + && (left.kind === 'direct' || (right.kind === 'context' && left.context === right.context)) +} + +function validateName(subject: string, value: string): void { + if (value.length === 0 || value.includes('/')) { + throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`) + } +} diff --git a/packages/typert/type-meta/src/invariant.ts b/packages/typert/type-meta/src/invariant.ts new file mode 100644 index 0000000000..22dc290a1e --- /dev/null +++ b/packages/typert/type-meta/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-type-meta`. + * @module @deepseek-ai/dsh-type-meta/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta' + +/** Cordis companion plugin name. */ +export const name = 'type-meta-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: decorators retain private immutable declarations and + * bindings are frozen values with no independent event stream to cross-check. + */ +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/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts new file mode 100644 index 0000000000..87ab091075 --- /dev/null +++ b/packages/typert/type-meta/src/types.ts @@ -0,0 +1,358 @@ +/** + * Compiler-independent TypeRT protocol shared by business packages, generated + * Remote artifacts, the Host Gateway, and Client API implementations. + * @module @deepseek-ai/dsh-type-meta/types + */ + +import type { Context } from 'cordis' + +declare const LOOKUP_HOST: unique symbol +declare const LOOKUP_WIRE: unique symbol +declare const CONTEXT_WIRE: unique symbol + +/** Type-level association between a Host object and its wire identity. */ +export interface TypeRTLookup { + readonly [LOOKUP_HOST]: Host + readonly [LOOKUP_WIRE]: Wire +} + +/** Extract the Host object associated with one lookup declaration. */ +export type TypeRTLookupHost = Lookup extends TypeRTLookup ? Host : never + +/** Extract the wire identity associated with one lookup declaration. */ +export type TypeRTLookupWire = Lookup extends TypeRTLookup ? Wire : never + +/** Type-level association between a scoped Context kind and its wire identity. */ +export interface TypeRTContext { + readonly [CONTEXT_WIRE]: Wire +} + +/** Extract the wire identity associated with one scoped Context declaration. */ +export type TypeRTContextWire = ContextType extends TypeRTContext ? Wire : never + +/** Merge-extensible Host object lookup declarations. */ +export interface TypeRTLookupMap {} + +/** Merge-extensible scoped Context declarations. */ +export interface TypeRTContextMap {} + +/** Merge-extensible direct Remote method signatures generated for consumers. */ +export interface TypeRTRemoteMap {} + +/** Merge-extensible scoped Remote method signatures generated for consumers. */ +export interface TypeRTRemoteContextMap {} + +/** + * Resolve one direct Remote namespace from the generated flat endpoint map. + * @template Namespace - wire namespace before the endpoint slash. + */ +export type TypeRTRemoteNamespace = { + [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteMap[Endpoint] +} + +/** + * Resolve one scoped Remote namespace across every generated Context kind. + * The calling Cordis Context supplies the concrete identity at runtime. + * @template Namespace - wire namespace between the Context prefix and method. + */ +export type TypeRTRemoteContextNamespace< + Namespace extends string, + ContextKey extends string = string, +> = { + [Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` + ? Method + : never]: TypeRTRemoteContextMap[Endpoint] +} + +type TypeRTRemoteContextNamespaceKey< + ContextKey extends string, + Endpoint = keyof TypeRTRemoteContextMap, +> = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never + +/** Generated scoped Remote namespaces available to one Context kind. */ +export type TypeRTRemoteContextApi = { + [Namespace in TypeRTRemoteContextNamespaceKey]: + TypeRTRemoteContextNamespace +} + +/** Merge-extensible direct namespace surface generated for Client API services. */ +export interface TypeRTRemoteNamespaceMap {} + +/** Awaitable disposer returned by Cordis-owned TypeRT registrations. */ +export type TypeRTDisposer = () => Promise + +type StringKeyOf = Extract + +/** Minimal runtime-schema capability carried by strict generated codecs. */ +export interface TypeRTSchema { + /** + * Parse and validate one boundary value. + * @param value - untrusted boundary value. + * @returns the validated value. + */ + parse(value: unknown): Output +} + +/** Codec attached to one invocation parameter or result. */ +export type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } + +/** One ordered business parameter in a Remote invocation. */ +export interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} + +/** Source position retained for diagnostics from generated definitions. */ +export interface InvocationSourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +/** Carrier-independent description of one exported method invocation. */ +export interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} + +/** Generated Host contract selected explicitly by a Client assembly. */ +export interface TypeRTRemoteContribution { + /** npm package that owns the Remote methods. */ + readonly package: string + /** Consumer-side invocation descriptors generated from that package. */ + readonly descriptors: readonly InvocationDescriptor[] +} + +/** Runtime resolver for one declared Host object lookup. */ +export interface TypeRTLookupProvider { + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string + /** + * Resolve a wire identity to the current live Host object. + * @param id - validated wire identity. + * @returns the live object, or `undefined` when it is unavailable. + */ + resolve(id: Wire): Host | undefined +} + +/** Host resolver for one scoped Remote Context kind. */ +export interface TypeRTHostContextProvider { + /** Wire field carrying the Context identity. */ + readonly wire: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string + /** + * Resolve a wire identity to its live scoped Context. + * @param id - validated wire identity. + * @returns the scoped Context, or `undefined` when unavailable. + */ + resolve(id: Wire): Context | undefined +} + +/** Client resolver for the identity carried by the calling scoped Context. */ +export interface TypeRTClientContextBinder { + /** + * Read the Remote identity represented by a calling Context. + * @param ctx - Context rebound by the Cordis service tracker. + * @returns the wire identity, or `undefined` when the Context has the wrong scope. + */ + identity(ctx: Context): Wire | undefined +} + +/** Notification emitted after a TypeRT runtime registry changes. */ +export interface TypeRTRegistryChange { + readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context' + readonly key: string +} + +/** Listener for one TypeRT runtime registry. */ +export type TypeRTRegistryListener = (change: TypeRTRegistryChange) => void + +/** Current-environment invocation definitions. */ +export interface TypeRTLocalRegistry { + /** + * Look up one invocation by `/`. + * @param endpoint - canonical endpoint. + * @returns the live descriptor, or `undefined` when absent. + */ + get(endpoint: string): InvocationDescriptor | undefined + /** + * Report whether a strict definition has existed during this TypeRT Service lifetime. + * @param endpoint - canonical endpoint. + * @returns `true` after the endpoint has been registered at least once, even if withdrawn. + */ + hasSeen(endpoint: string): boolean + /** @returns a registration-order snapshot of local descriptors. */ + list(): readonly InvocationDescriptor[] + /** + * Observe later local-definition changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Consumer-selected Remote contribution registry. */ +export interface TypeRTRemoteRegistry { + /** + * Register one generated contribution for the calling Cordis fiber. + * @param contribution - generated Remote descriptors. + * @returns disposer withdrawing the exact contribution. + */ + register(contribution: TypeRTRemoteContribution): TypeRTDisposer + /** + * Look up one Remote descriptor by endpoint. + * @param endpoint - canonical endpoint. + * @returns the descriptor, or `undefined` when unmounted. + */ + get(endpoint: string): InvocationDescriptor | undefined + /** @returns a registration-order snapshot of Remote descriptors. */ + list(): readonly InvocationDescriptor[] + /** + * Observe later Remote contribution changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Runtime registry for Host object lookup providers. */ +export interface TypeRTLookupRegistry { + /** + * Register one provider under its merge-declared key. + * @param key - lookup key. + * @param provider - owning package's live resolver. + * @returns disposer withdrawing the exact provider. + */ + register>( + key: K, + provider: TypeRTLookupProvider< + TypeRTLookupHost, + TypeRTLookupWire + >, + ): TypeRTDisposer + /** + * Look up one provider by runtime key. + * @param key - descriptor lookup key. + * @returns the live provider, or `undefined` when absent. + */ + get(key: string): TypeRTLookupProvider | undefined + /** @returns a snapshot of registered provider keys. */ + keys(): readonly string[] + /** + * Observe later lookup changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Runtime registry for Host Context resolvers and Client Context binders. */ +export interface TypeRTContextRegistry { + /** + * Register a Host Context resolver. + * @param key - merge-declared Context key. + * @param provider - owning package's Host resolver. + * @returns disposer withdrawing the exact provider. + */ + registerHost>( + key: K, + provider: TypeRTHostContextProvider>, + ): TypeRTDisposer + /** + * Register a Client Context identity binder. + * @param key - merge-declared Context key. + * @param binder - Client scope identity resolver. + * @returns disposer withdrawing the exact binder. + */ + registerClient>( + key: K, + binder: TypeRTClientContextBinder>, + ): TypeRTDisposer + /** + * Look up a Host Context resolver. + * @param key - descriptor Context key. + * @returns the provider, or `undefined` when absent. + */ + getHost(key: string): TypeRTHostContextProvider | undefined + /** + * Look up a Client Context binder. + * @param key - descriptor Context key. + * @returns the binder, or `undefined` when absent. + */ + getClient(key: string): TypeRTClientContextBinder | undefined + /** + * Observe later Context provider changes. + * @param listener - synchronous contained observer. + * @returns disposer for this subscription. + */ + subscribe(listener: TypeRTRegistryListener): TypeRTDisposer +} + +/** Minimal TypeRT runtime consumed through dependency inversion. */ +export interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} + +declare module 'cordis' { + interface Context { + typert: TypeRTService + } +} diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts new file mode 100644 index 0000000000..68f886dff1 --- /dev/null +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -0,0 +1,29 @@ +import { + bindTypeRTGateway, + Remote, + RemoteContext, + remoteMethods, +} from '@deepseek-ai/dsh-type-meta' + +class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + create(value: string): string { + return value + } + + @RemoteContext('agent') + scoped(value: string): string { + return value + } +} + +const methods = remoteMethods(new Goals()) +const actual = JSON.stringify(methods) +const expected = JSON.stringify([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'agent' } }, +]) +if (actual !== expected) throw new Error(`unexpected Remote declarations: ${actual}`) +process.stdout.write(actual) diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts new file mode 100644 index 0000000000..1eab5a6ca3 --- /dev/null +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -0,0 +1,132 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + bindTypeRTGateway, + Remote, + RemoteContext, + remoteMethods, + type TypeRTContext, +} from '@deepseek-ai/dsh-type-meta' + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + metaFixture: TypeRTContext + } +} + +describe('type-meta Remote declarations', () => { + it('executes standard decorator syntax through the Vitest source transform', () => { + class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + @Remote + create(value: string): string { + return value + } + + @RemoteContext('metaFixture') + scoped(value: string): string { + return value + } + } + + const goals = new Goals() + expect(remoteMethods(goals)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + }) + + it('executes standard decorator syntax through the TSX source launcher', () => { + const fixture = fileURLToPath(new URL('./fixtures/source-launch.ts', import.meta.url)) + const output = execFileSync(process.execPath, ['--import', 'tsx/esm', fixture], { encoding: 'utf8' }) + expect(JSON.parse(output)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'agent' } }, + ]) + }) + + it('keeps decorator markers in private module state', () => { + class Goals { + readonly typertGateway = bindTypeRTGateway(this, 'goals') + + create(agent: object, request: object): object { + return { agent, request } + } + + scoped(request: object): object { + return request + } + } + + const initializers: Array<(this: Goals) => void> = [] + Remote( + Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown, + methodContext('create', initializers), + ) + RemoteContext('metaFixture')( + Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown, + methodContext('scoped', initializers), + ) + + const goals = new Goals() + for (const initialize of initializers) initialize.call(goals) + expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' }) + expect(Object.isFrozen(goals.typertGateway)).toBe(true) + expect(remoteMethods(goals)).toEqual([ + { method: 'create', invocation: { kind: 'direct' } }, + { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + expect(Reflect.ownKeys(Goals)).toEqual(['length', 'name', 'prototype']) + expect(Reflect.ownKeys(Goals.prototype)).toEqual(['constructor', 'create', 'scoped']) + }) + + it('keeps markers idempotent across instances and returns detached snapshots', () => { + class Service { + run(value: string): string { + return value + } + } + + const initializers: Array<(this: Service) => void> = [] + Remote( + Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, + methodContext('run', initializers), + ) + + const first = new Service() + const second = new Service() + for (const initialize of initializers) { + initialize.call(first) + initialize.call(second) + } + const snapshot = remoteMethods(first) + expect(remoteMethods(second)).toEqual(snapshot) + ;(snapshot as unknown as { method: string }[])[0]!.method = 'changed' + expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }]) + }) + + it('rejects ambiguous binding names', () => { + expect(() => bindTypeRTGateway({}, '')).toThrow('service key') + expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') + }) +}) + +function methodContext( + name: string, + initializers: Array<(this: This) => void>, +): ClassMethodDecoratorContext unknown> { + return { + kind: 'method', + name, + static: false, + private: false, + metadata: {}, + access: { + has: object => name in object, + get: object => (object as Record)[name] as (this: This, ...args: unknown[]) => unknown, + }, + addInitializer: (initializer) => { initializers.push(initializer) }, + } +} diff --git a/packages/typert/type-meta/tsconfig.json b/packages/typert/type-meta/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/typert/type-meta/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ea3070dfa..45e8ad1803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -916,6 +916,9 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../goal/goal-session + '@deepseek-ai/dsh-host-api-gateway': + specifier: workspace:^ + version: link:../../host/api-gateway '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1054,6 +1057,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-loader': + specifier: workspace:^ + version: link:../../typert/loader + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval @@ -2790,6 +2799,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -2854,6 +2869,12 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -3706,6 +3727,31 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/host/api-gateway: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/host/apiproxy: dependencies: '@deepseek-ai/dsh-agent': @@ -6096,6 +6142,9 @@ importers: packages/typert/generator: dependencies: + '@jridgewell/gen-mapping': + specifier: ^0.3.13 + version: 0.3.13 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -6140,6 +6189,9 @@ importers: packages/typert/registry: dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../type-meta zod: specifier: ^4.4.3 version: 4.4.3 @@ -6151,6 +6203,15 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/typert/type-meta: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/ui/app-boot: dependencies: js-yaml: diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 8b10822bca..fb47f8a9c8 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -59,6 +59,13 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() }) + it('lets exact generated Remote contributions inline without admitting their package implementation', () => { + expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull() + expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/) + }) + it('throws on any other @deepseek-ai leak', () => { expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bcf90d1e82..84013225f7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -276,6 +276,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md', TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md', TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md', + TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md', 'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API', 'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', @@ -287,6 +288,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', + InvokeRemoteRequest: 'gateway invocation contract is owned by packages/host/api-gateway/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1c7e2c3a0c..151aadb278 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -140,8 +140,15 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'typert-registry', title: 'Runtime type registry', mode: 'core', - consumers: ['typert-loader'], - note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.', + consumers: ['typert-loader', 'api-gateway'], + note: 'Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges.', + }, + { + key: 'typertGateway', + pkg: 'api-gateway', + title: 'TypeRT Host invocation gateway', + mode: 'core', + note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.', }, { key: 'sessionPersistence', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 53491b89fb..7e81b30e07 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -125,6 +125,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, + 'packages/host/api-gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 001158afe0..ce4fca35f9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -40,6 +40,13 @@ "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], "@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-registry/client": ["./packages/typert/registry/src/client/index.ts"], + "@deepseek-ai/dsh-host-api-gateway": ["./packages/host/api-gateway/src/index.ts"], + "@deepseek-ai/dsh-host-api-gateway/client": ["./packages/host/api-gateway/src/client/index.ts"], + "@deepseek-ai/dsh-host-api-gateway/invariant": ["./packages/host/api-gateway/src/invariant.ts"], + "@deepseek-ai/dsh-host-api-gateway/types": ["./packages/host/api-gateway/src/types.ts"], + "@deepseek-ai/dsh-type-meta": ["./packages/typert/type-meta/src/index.ts"], + "@deepseek-ai/dsh-type-meta/types": ["./packages/typert/type-meta/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"], @@ -68,7 +75,6 @@ "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], - "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], @@ -145,6 +151,8 @@ "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], + "@deepseek-ai/dsh-client-remotes": ["./packages/client/remotes/src"], + "@deepseek-ai/dsh-client-remotes/client": ["./packages/client/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 03a2b8bb59..b0567f762e 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -51,6 +51,8 @@ { "path": "./packages/client/modules" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, + { "path": "./packages/typert/registry" }, + { "path": "./packages/host/api-gateway" }, { "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 79276dfe7e..37c20c0d5c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -100,7 +100,9 @@ { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, + { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, + { "path": "./packages/host/api-gateway" }, { "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 41a490436f..0d503c62d3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'tsdown' +import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' /** * JS bundling for vendored Cordis and Harness TypeScript packages. @@ -27,4 +28,7 @@ export default defineConfig({ 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' })], }) diff --git a/tsdown.typert-host.config.ts b/tsdown.typert-host.config.ts new file mode 100644 index 0000000000..8c8ae11dd1 --- /dev/null +++ b/tsdown.typert-host.config.ts @@ -0,0 +1,20 @@ +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'] })], +}) diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..4c4c668b94 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' +import ts from 'typescript' import { vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' @@ -17,6 +18,29 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc // map applies to every test file. paths must win over package exports so built // lib/ never loads a second module-singleton copy. const pathsPlugin = (): ReturnType => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) +const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m + +const standardDecoratorPlugin = () => ({ + name: 'dsh-standard-decorators', + enforce: 'pre' as const, + transform(code: string, id: string) { + const file = id.split('?', 1)[0]! + if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, + sourceMap: true, + }, + }) + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, +}) const windowsUnsupportedPackages = process.platform === 'win32' ? [ @@ -88,7 +112,7 @@ const processBoundTests = [ ] export default defineConfig({ - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). @@ -99,7 +123,7 @@ export default defineConfig({ // always fork. projects: [ { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'thread-safe', execArgv: vitestExecArgv, @@ -119,7 +143,7 @@ export default defineConfig({ }, }, { - plugins: [pathsPlugin()], + plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { name: 'process-bound', execArgv: vitestExecArgv, From 9a0a9350c44bf20e57c37daace7fb6746e5d9d00 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:18:35 +0800 Subject: [PATCH 31/88] fix(typert): satisfy workspace static gates --- THIRD_PARTY_NOTICES.md | 1 + docs/cordis-catalog/services.md | 2 +- knip.json | 3 +- packages/client/connection/src/client/rpc.ts | 1 - packages/client/connection/src/http-bridge.ts | 6 +- packages/client/connection/src/rpc-host.ts | 26 +- .../connection/tests/client-apply.spec.ts | 43 +++ .../connection/tests/http-bridge.spec.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 68 +++- packages/host/api-gateway/package.json | 4 +- packages/host/api-gateway/src/client/index.ts | 9 +- packages/host/api-gateway/src/index.ts | 10 +- .../host/api-gateway/tests/client.spec.ts | 138 ++++++++ .../host/api-gateway/tests/gateway.spec.ts | 294 ++++++++++++++++++ packages/typert/generator/src/emitter.ts | 27 +- .../typert/generator/src/tsdown-plugin.ts | 35 ++- .../generator/tests/tsdown-plugin.spec.ts | 7 + packages/typert/registry/src/service.ts | 7 + packages/typert/registry/tests/typert.spec.ts | 149 +++++++++ packages/typert/type-meta/package.json | 4 +- .../typert/type-meta/tests/type-meta.spec.ts | 74 +++++ pnpm-lock.yaml | 3 + python/sdk-runtime/package.json | 1 + scripts/check-workspace-constraints.ts | 34 +- scripts/dev-web.spec.ts | 8 +- scripts/dev-web.ts | 23 +- scripts/publication-payload.spec.ts | 31 +- scripts/publication-payload.ts | 36 ++- scripts/publish-npm-baseline.ts | 14 +- 29 files changed, 986 insertions(+), 74 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f70245d49d..e53dd292e4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | +| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0a9af0bae5..41059aebf4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2585,7 +2585,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:324`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` diff --git a/knip.json b/knip.json index 7a3922c8fb..32c9e20dbf 100644 --- a/knip.json +++ b/knip.json @@ -200,7 +200,8 @@ "packages/typert/generator": { "entry": [ "tests/**/*.spec.ts", - "tests/fixtures/type-model/**/*.ts" + "tests/fixtures/type-model/**/*.ts", + "tests/fixtures/remote-model/**/*.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 36e16426b2..0c12149d7b 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -67,7 +67,6 @@ function resolveBase(): string { function assertTarget(channel: string, endpoint: string): void { const segments = endpoint.split('/') if (!CHANNEL_PATTERN.test(channel) - || segments.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`) diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 319d3e0b0b..88d577bef8 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +interface FetchHandler { + fetch(request: Request): Promise +} + /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; SSE bodies stream out chunk by chunk). @@ -12,7 +16,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. */ -export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { +export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index be9eedca8f..a6fbdb0264 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -7,6 +7,7 @@ import { RpcId, type ClientRequest, type RpcError, + type RpcErrorDetailsMap, type RpcId as RpcIdType, type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -73,10 +74,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): { fetch: typeof fetch } { +): { fetch(request: Request): Promise } { return { - async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const request = input instanceof Request ? input : new Request(input, init) + async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) @@ -96,13 +96,7 @@ function rpcFetchHandler( const envelope = clientRequestSchema.safeParse(body) if (!envelope.success) { - const rawId = (body as { rpcId?: unknown } | null)?.rpcId - const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID - return errorResponse(rpcId, { - code: 'bad-request', - message: 'invalid client-request message', - details: { issues: envelope.error.issues }, - }) + return invalidEnvelopeResponse(body, envelope.error.issues) } const message: ClientRequest = envelope.data if (message.method !== endpoint) { @@ -123,11 +117,21 @@ function rpcFetchHandler( } } +function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response { + const rawId = (body as { rpcId?: unknown } | null)?.rpcId + const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID + return errorResponse(rpcId, { + code: 'bad-request', + message: 'invalid client-request message', + details: { issues }, + }) +} + function endpointFromPath(channel: string, pathname: string): string | undefined { if (!pathname.startsWith(`${channel}/`)) return undefined const endpoint = pathname.slice(channel.length + 1) const segments = endpoint.split('/') - if (segments.length === 0 || segments.some(segment => + if (segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { return undefined } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index d93844a2b8..3ce8b89ecb 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -235,6 +235,49 @@ describe('connection client apply', () => { }) }) + it('validates generic RPC transport failures, correlation, and targets', async () => { + ;(globalThis as Win).location = { + hostname: 'harness.example', search: '', origin: 'https://harness.example', + } + const handle = await mount() + const original = globalThis.fetch + const abort = new AbortController() + globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })) + try { + await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal)) + .rejects.toThrow('HTTP 503') + expect(globalThis.fetch).toHaveBeenCalledWith( + new URL('https://harness.example/api2/goals/create'), + expect.objectContaining({ signal: abort.signal }), + ) + + ;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' } + globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ + type: 'server-response', + rpcId: 'different-rpc', + result: { ok: true, value: null }, + })) + await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch') + const fetch = vi.mocked(globalThis.fetch) + expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create')) + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + } finally { + globalThis.fetch = original + } + + for (const [channel, endpoint] of [ + ['api2', 'goals/create'], + ['/api2/path', 'goals/create'], + ['/api2', ''], + ['/api2', '.'], + ['/api2', '..'], + ['/api2', 'goals//create'], + ['/api2', 'goals/create?unsafe'], + ] as const) { + await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target') + } + }) + it('keeps generic Remote calls unavailable in the client-only fixture', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts index 4607f32bae..b06834e523 100644 --- a/packages/client/connection/tests/http-bridge.spec.ts +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => { let carrierSignal: AbortSignal | undefined const pending = bridge(request, response, { fetch: async (input) => { - const fetchRequest = input as Request + const fetchRequest = input carrierSignal = fetchRequest.signal resolveStarted() if (!fetchRequest.signal.aborted) { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index af85d4e510..1c42a9dc88 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -47,6 +47,13 @@ function fakePost(headers: Record, url: string, body: unknown): return request } +/** Raw POST for malformed-body and media-type boundary cases. */ +function fakeRawPost(headers: Record, url: string, body: string): IncomingMessage { + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { url, method: 'POST', headers }) + return request +} + /** Response recorder compatible with both the fence's short-circuit and the bridge. */ function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { const state: { status?: number; body?: unknown } = {} @@ -239,7 +246,10 @@ describe('connection node half', () => { const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle - const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + const remove = connection.rpc.handle('/api2', async (endpoint) => { + if (endpoint === 'fail') throw new Error('handler broke') + return { ok: true, value: null } + }, { authority: 'trusted-host', }) const route = routes[0]! @@ -248,14 +258,64 @@ describe('connection node half', () => { await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) - const badEnvelope = fakeResponse() + const methodMismatch = fakeResponse() await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, - }), badEnvelope.response) - expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({ + }), methodMismatch.response) + expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ rpcId: 'rpc-bad', result: { ok: false, error: { code: 'bad-request' } }, }) + + for (const [request, status] of [ + [fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404], + [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], + [fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404], + [fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400], + ] as const) { + const response = fakeResponse() + await route.handler(request, response.response) + expect(response.state.status).toBe(status) + } + + for (const [body, rpcId] of [ + [{ rpcId: 'retained-id' }, 'retained-id'], + [{ rpcId: 42 }, 'invalid-request'], + [null, 'invalid-request'], + ] as const) { + const response = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response) + expect(JSON.parse(String(response.state.body))).toMatchObject({ + rpcId, + result: { ok: false, error: { code: 'bad-request' } }, + }) + } + + const failed = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', { + type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, + }), failed.response) + expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) + + expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + + const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), { + authority: 'loopback', + }) + const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')! + const publicResponse = fakeResponse() + await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', { + type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {}, + }), publicResponse.response) + expect(publicResponse.state.status).toBe(403) + await removeLoopback() await remove() await fiber.dispose() }) diff --git a/packages/host/api-gateway/package.json b/packages/host/api-gateway/package.json index 3f3c905f1d..794ae323aa 100644 --- a/packages/host/api-gateway/package.json +++ b/packages/host/api-gateway/package.json @@ -43,9 +43,7 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 57116db2cf..fe8fd9f1b3 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -90,7 +90,8 @@ class ClientApiService extends Service implements ClientApi { } }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) } catch (error) { - disposeRemote().catch(() => {}) + /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ + Promise.resolve(disposeRemote()).catch(() => {}) throw error } return async () => { @@ -148,6 +149,7 @@ class ClientApiService extends Service implements ClientApi { const projection = scopedProjection(descriptor) if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) return () => { + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return token.active = false for (const dispose of installed.reverse()) dispose() @@ -173,6 +175,7 @@ class ClientApiService extends Service implements ClientApi { value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), }) return () => { + /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return Reflect.deleteProperty(namespace.value, descriptor.method) namespace.tokens.delete(descriptor.method) @@ -203,6 +206,7 @@ class ClientApiService extends Service implements ClientApi { namespace.tokens.set(descriptor.method, token) namespace.service.install(descriptor, projection, token) return () => { + /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) @@ -289,9 +293,6 @@ class ScopedRemoteNamespace extends Service { }, }) this.methods.add(method) - if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) { - this.ownerCtx.set(this.name, this) - } } remove(method: string): void { diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index ccb76e2d48..c83772261a 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -156,11 +156,10 @@ export class TypertGatewayService extends Service implements TypertGateway { private async invokeRpc(endpoint: string, payload: unknown): Promise { try { const segments = endpoint.split('/') - const namespace = segments[0] - const method = segments[1] - if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') { + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) } + const [namespace, method] = segments as [string, string] if (!isObject(payload) || !isPlainObject(payload) || Reflect.ownKeys(payload).length !== 1 @@ -358,6 +357,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup + /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { throw new TypertGatewayError( 'lookup-unavailable', @@ -492,11 +492,11 @@ function methodParameterNames(service: object, method: string, endpoint: string) const source = Function.prototype.toString.call(implementation) const open = source.indexOf('(') const close = source.indexOf(')', open + 1) + /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */ if (open < 0 || close < 0) return invalidSignature(endpoint, method) const body = source.slice(open + 1, close).trim() if (body.length === 0) return [] const parts = body.split(',').map(part => part.trim()) - if (parts.at(-1) === '') parts.pop() const names = new Set() for (const part of parts) { if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) @@ -579,8 +579,8 @@ function assertJsonValue(value: unknown, ancestors: Set): void { if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe') const descriptor = Object.getOwnPropertyDescriptor(value, key) + /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */ if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { throw new TypeError('non-data property is not JSON-safe') } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index be0b12ed51..8c0753f3f9 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -203,6 +203,144 @@ describe('Client TypeRT API', () => { expect(ctx.typert.remotes.list()).toEqual([]) }) + it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + + expect(() => ctx.api.mount({ + package: '@fixture/direct-duplicates', + descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], + })).toThrow('repeats direct method') + expect(() => ctx.api.mount({ + package: '@fixture/scoped-duplicates', + descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], + })).toThrow('repeats scoped method') + + const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) + expect(() => ctx.api.mount({ + package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], + })).toThrow('direct method goals/create is already mounted') + await disposeDirect() + + const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) + expect(() => ctx.api.mount({ + package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], + })).toThrow('scoped method goals/rename is already mounted') + expect(() => ctx.api.mount({ + package: '@fixture/service-method-conflict', + descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], + })).toThrow('conflicts with its namespace service') + await disposeScoped() + + expect(() => ctx.api.mount({ + package: '@fixture/context-property-conflict', + descriptors: [{ ...context, namespace: 'typert' }], + })).toThrow('conflicts with an existing Context property') + + const disposeMultipleScoped = ctx.api.mount({ + package: '@fixture/multiple-scoped', + descriptors: [directDescriptor(), contextDescriptor()], + }) + await disposeMultipleScoped() + }) + + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + expect(() => ctx.api.mount({ + package: '@fixture/weak-parameter', + descriptors: [{ + ...direct, + parameters: direct.parameters.map((parameter, index) => index === 0 + ? { ...parameter, codec: { mode: 'src-json' } } + : parameter), + }], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/weak-context', + descriptors: [{ + ...context, + invocation: { ...context.invocation, codec: { mode: 'src-json' } }, + } as InvocationDescriptor], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/malformed-scope', + descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], + })).toThrow('scope must select its only lookup parameter') + expect(() => ctx.api.mount({ + package: '@fixture/ambiguous-scope', + descriptors: [{ + ...direct, + parameters: [...direct.parameters, { + name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }], + }], + })).toThrow('scope must select its only lookup parameter') + }) + + it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const descriptor = directDescriptor() + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + + await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + .rejects.toThrow('no Client Context binder') + + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' + + ctx.set('connection', undefined) + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await dispose() + }) + + it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => { + let resolveCall!: (result: Awaited>) => void + const pending = new Promise>>((resolve) => { + resolveCall = resolve + }) + const call = vi.fn().mockReturnValue(pending) + const ctx = await bench(call) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + await dispose() + resolveCall({ ok: true, value: { ref: 'goal-1' } }) + + await expect(invocation).rejects.toThrow('withdrawn during invocation') + expect((ctx.api as unknown as Record).goals).toBeUndefined() + }) + + it('rolls back Remote registration when concrete method installation fails', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'goals') throw new Error('fixture installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .toThrow('fixture installation failure') + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + } finally { + spy.mockRestore() + } + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 8f7c144f5e..0b550e126d 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -229,6 +229,96 @@ class WrongBindingService extends Service { } } +class ExportedMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'exportedMethod', { namespace: 'exported' }) + + constructor(ctx: Context) { + super(ctx, 'exportedMethod') + } + + @Remote('execute') + run(value: string): string { + return value + } +} + +class EmptyMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'emptyMethod', { namespace: 'empty' }) + + constructor(ctx: Context) { + super(ctx, 'emptyMethod') + } + + @Remote + ping(): string { + return 'pong' + } +} + +class CollidingWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'collidingWire', { namespace: 'colliding-wire' }) + + constructor(ctx: Context) { + super(ctx, 'collidingWire') + } + + @Remote + run(agent: FixtureAgent, agentId: string): string { + return `${agent.id}:${agentId}` + } +} + +class ContextWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'contextWire', { namespace: 'context-wire' }) + + constructor(ctx: Context) { + super(ctx, 'contextWire') + } + + @RemoteContext('gatewayFixture') + run(agentId: string): string { + return agentId + } +} + +class NoBindingService extends Service { + constructor(ctx: Context) { + super(ctx, 'noBinding') + } + + run(value: string): string { + return value + } +} + +class MissingMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' }) + + constructor(ctx: Context) { + super(ctx, 'missingMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodBase extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'inheritedMethod', { namespace: 'inherited' }) + + constructor(ctx: Context) { + super(ctx, 'inheritedMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodService extends InheritedMethodBase {} + describe('TypertGatewayService', () => { it('invokes a strict direct method with schema decoding and a live lookup', async () => { const { ctx, service } = await setup() @@ -284,6 +374,53 @@ describe('TypertGatewayService', () => { })).resolves.toEqual({ title: 'land', scope: 'agent-src' }) }) + it('derives exported, empty, inherited, and distinct-namespace SRC methods', async () => { + const ctx = await setupGateway() + await ctx.plugin(ExportedMethodService) + await ctx.plugin(EmptyMethodService) + await ctx.plugin(InheritedMethodService) + + await expect(ctx.typertGateway.invoke({ + namespace: 'exported', method: 'execute', args: { value: 'ship' }, + })).resolves.toBe('ship') + await expect(ctx.typertGateway.invoke({ + namespace: 'empty', method: 'ping', args: {}, + })).resolves.toBe('pong') + await expect(ctx.typertGateway.invoke({ + namespace: 'inherited', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'other', method: 'absent', args: {}, + }), 'invocation-unavailable') + }) + + it('rejects SRC wire collisions and unavailable Context providers', async () => { + const colliding = await setupGateway() + await colliding.plugin(CollidingWireService) + registerAgentLookup(colliding, { id: 'agent-1' }) + await expectCode(colliding.typertGateway.invoke({ + namespace: 'colliding-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + + const missing = await setup() + await expectCode(missing.ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-unavailable') + + const contextCollision = await setupGateway() + await contextCollision.plugin(ContextWireService) + contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) + await expectCode(contextCollision.typertGateway.invoke({ + namespace: 'context-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + }) + it('re-reads Service and providers on every strict invocation', async () => { const { ctx, serviceFiber } = await setup() const agent = { id: 'agent-1' } @@ -331,6 +468,58 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [renameDescriptor()]) + const scoped = ctx.extend() + const mismatch = ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + wire: 'differentAgentId', + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'provider-mismatch') + await mismatch() + + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-not-found') + }) + + it('contains lookup provider failures and missing identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [createDescriptor()]) + const throwing = ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => { throw new Error('lookup failed') }, + }) + const failure = await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-failed') + expect(failure.cause).toEqual(new Error('lookup failed')) + await throwing() + + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-not-found') + }) + it('never downgrades an observed strict endpoint after definition disposal', async () => { const { ctx } = await setup() const dispose = registerStrict(ctx, [passthroughDescriptor()]) @@ -434,6 +623,11 @@ describe('TypertGatewayService', () => { method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'arguments-invalid') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: [] as unknown as Record, + }), 'arguments-invalid') expect(service.calls).toEqual([]) }) @@ -492,6 +686,31 @@ describe('TypertGatewayService', () => { }), 'result-invalid') }) + it('accepts dense JSON and rejects decorated arrays and object properties', async () => { + const { ctx } = await setup() + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: [1, { nested: true }] }, + })).resolves.toEqual([1, { nested: true }]) + + const sparseWithExtra = Array(1) as unknown[] & { extra?: boolean } + sparseWithExtra.extra = true + const symbolArray = [1] + Object.defineProperty(symbolArray, Symbol('extra'), { value: true }) + const symbolObject = { value: true } + Object.defineProperty(symbolObject, Symbol('extra'), { value: true }) + const hidden = {} + Object.defineProperty(hidden, 'value', { value: true, enumerable: false }) + const accessor = {} + Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) + for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', method: 'passthrough', args: { value }, + }), 'input-invalid') + } + }) + it('validates strict provider identity against generated wire metadata', async () => { const { ctx } = await setup() ctx.typert.lookups.register('gatewayFixture', { @@ -525,6 +744,61 @@ describe('TypertGatewayService', () => { }), 'method-unavailable') }) + it('requires a visible binding and supports explicitly provided plain Services', async () => { + const ctx = await setupGateway() + await ctx.plugin(NoBindingService) + registerStrict(ctx, [{ + ...passthroughDescriptor(), + id: '@fixture/gateway#no-binding/run', + service: 'noBinding', + namespace: 'no-binding', + method: 'run', + }]) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'no-binding', method: 'run', args: { value: 'ship' }, + }), 'binding-invalid') + + const plain: { + typertGateway?: ReturnType + run(value: string): string + } = { run: value => value } + plain.typertGateway = bindTypeRTGateway(plain, 'plainRemote', { namespace: 'plain' }) + ctx.provide('plainRemote', plain) + ctx.typert.register({ + package: '@fixture/plain', + face: 'host', + schemas: [], + model: emptyModel, + invocations: [{ + ...passthroughDescriptor(), + id: '@fixture/plain#plain/run', + service: 'plainRemote', + namespace: 'plain', + method: 'run', + }], + }) + await expect(ctx.typertGateway.invoke({ + namespace: 'plain', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + }) + + it('reports a SRC marker whose prototype implementation disappeared', async () => { + const ctx = await setupGateway() + await ctx.plugin(MissingMethodService) + const descriptor = Object.getOwnPropertyDescriptor(MissingMethodService.prototype, 'run')! + Object.defineProperty(MissingMethodService.prototype, 'run', { + configurable: true, + value: 42, + }) + try { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'missing-method', method: 'run', args: { value: 'ship' }, + }), 'method-unavailable') + } finally { + Object.defineProperty(MissingMethodService.prototype, 'run', descriptor) + } + }) + it('preserves business exception identity after invocation begins', async () => { const { ctx, service } = await setup() const failure = new Error('business identity') @@ -575,6 +849,26 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { + const result = await handler(endpoint, { args: {} }, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded') + expect(result.error.message).toContain('invalid Remote endpoint') + } + for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) { + const result = await handler('goals/create', payload, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded') + expect(result.error.message).toContain('plain-object args field') + } + + const service = rawGoalService(ctx) + service.businessError = 'non-error failure' as unknown as Error + await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ + ok: false, + error: { code: 'internal', message: 'non-error failure', details: {} }, + }) + await gatewayFiber.dispose() expect(connection.handler).toBeUndefined() }) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 3e79780593..63b1ee7ace 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -399,21 +399,9 @@ export class FaceModelEmitter { scoped: boolean, ): void { const signature = this.remoteSignature(invocation, referenceNames, scoped) - const line = ` ${signature}` - lines.push(line) - const generatedLine = lines.length const keyLength = signature.indexOf(': (') if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) - const source = remoteDeclarationSource(packageModel, invocation) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 }, - source, - original: { line: invocation.location.line, column: invocation.location.column - 1 }, - name: invocation.method, - }) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + keyLength }, - }) + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, keyLength) } private pushRemoteNamespaceSignature( @@ -424,6 +412,17 @@ export class FaceModelEmitter { referenceNames: ReadonlyMap, ): void { const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length) + } + + private pushMappedRemoteSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + signature: string, + keyLength: number, + ): void { lines.push(` ${signature}`) const generatedLine = lines.length const source = remoteDeclarationSource(packageModel, invocation) @@ -434,7 +433,7 @@ export class FaceModelEmitter { name: invocation.method, }) addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + invocation.method.length }, + generated: { line: generatedLine, column: 4 + keyLength }, }) } diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index a5c6ef93e2..10cba60974 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -1,23 +1,27 @@ /** - * Optional tsdown (rolldown) plugin face of the typert generator. When added - * to a workspace tsdown config, it runs after each opted-in package bundle is - * written and re-emits its model-driven face artifact at the package output - * root. Packages without a Typert or Remote export are skipped. + * Optional tsdown (rolldown) plugin face of the typert generator. It lowers + * standard decorators in TypeScript dependencies before bundling, then emits + * model-driven face artifacts at the package output root. Packages without a + * Typert or Remote export are skipped. * @module @deepseek-ai/dsh-typert-generator/tsdown */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import ts from 'typescript' import { WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' import type { TypertFace } from './model.ts' -/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ +/** The subset of the rolldown plugin contract used here (structural; avoids a rolldown type dependency). */ interface TypertPlugin { name: string + transform: (code: string, id: string) => { code: string; map: string | undefined } | undefined writeBundle: (options: { dir?: string }) => void } +const DECORATOR_SYNTAX = /^\s*@[A-Za-z_$][\w$]*/m + /** Generation scope selected by a tsdown build phase. */ export interface TypertPluginOptions { /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ @@ -27,15 +31,32 @@ export interface TypertPluginOptions { } /** - * Create the typert generation plugin for the root tsdown config. + * Create the decorator-lowering and typert-generation plugin for the root tsdown config. * @param pluginOptions - package/workspace emission mode and independent program faces. - * @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts. + * @returns a rolldown-compatible plugin that lowers source decorators and emits local and Host-for-Client artifacts. */ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin { const artifactsByRoot = new Map() const emittedWorkspaces = new Set() return { name: 'dsh-typert-generator', + transform(code, id) { + const file = id.split('?', 1)[0] ?? id + if (!/\.[cm]?tsx?$/.test(file) || !DECORATOR_SYNTAX.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + ...(file.endsWith('x') ? { jsx: ts.JsxEmit.ReactJSX } : {}), + sourceMap: true, + }, + }) + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, writeBundle(bundleOptions) { // options.dir is the package's absolute outDir (/lib); its // nearest package.json owns the bundle even when a custom config writes diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 655636aa79..106b8950ff 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -64,6 +64,13 @@ afterEach(() => { }) describe('typertPlugin', () => { + it('lowers standard decorators in TypeScript source dependencies', () => { + const plugin = typertPlugin() + expect(plugin.transform('export const value = 1\n', '/workspace/src/plain.ts')).toBeUndefined() + expect(plugin.transform('@sealed\nexport class Example {}\n', '/workspace/src/example.ts')?.code) + .not.toContain('@sealed') + }) + it('skips outputs that do not identify a Typert contributor', async () => { const plugin = typertPlugin() expect(plugin.name).toBe('dsh-typert-generator') diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 16160a3860..4973732fad 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -149,8 +149,10 @@ class DescriptorStore { for (const descriptor of descriptors) { const endpoint = typertEndpoint(descriptor) const entry = this.entries.get(endpoint) + /* v8 ignore next -- duplicate registration is rejected, so no later owner can replace this entry before its effect disposes. */ if (entry?.owner !== owner) continue this.entries.delete(endpoint) + /* v8 ignore next -- ids and endpoints are committed and withdrawn together under the same unique owner. */ if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id) removed.push(endpoint) } @@ -200,6 +202,7 @@ class RemoteStore { packages.set(contribution.package, owner) descriptors.commit(owner, contribution.descriptors) yield () => { + /* v8 ignore else -- duplicate package registration is rejected, so this effect remains the package's unique owner. */ if (packages.get(contribution.package) === owner) packages.delete(contribution.package) descriptors.withdraw(owner, contribution.descriptors) } @@ -244,6 +247,7 @@ class LookupStore { providers.set(key, entry) changes.emit({ kind: 'lookup', key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (providers.get(key) !== entry) return providers.delete(key) changes.emit({ kind: 'lookup', key }) @@ -303,6 +307,7 @@ class ContextStore { table.set(key, entry) changes.emit({ kind, key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (table.get(key) !== entry) return table.delete(key) changes.emit({ kind, key }) @@ -381,8 +386,10 @@ export class TypertRegistry extends Service implements TypeRTService { for (const record of schemaRecords) schemas.set(record.key, record) localStore.commit(owner, invocations) yield () => { + /* v8 ignore else -- duplicate package-face registration is rejected, so this effect remains its unique owner. */ if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key) for (const record of schemaRecords) { + /* v8 ignore else -- duplicate schema registration is rejected, so this contribution remains each record's unique owner. */ if (schemas.get(record.key) === record) schemas.delete(record.key) } localStore.withdraw(owner, invocations) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index a98f99f912..95f8bc871f 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -13,6 +13,7 @@ import type { TypeRTLookup, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' +import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts' declare module '@deepseek-ai/dsh-type-meta' { interface TypeRTLookupMap { @@ -222,6 +223,29 @@ describe('TypertRegistry', () => { expect(changes).toEqual(['local:goals/create', 'local:goals/create']) }) + it('rejects duplicate invocation endpoints and ids atomically', async () => { + const ctx = await makeCtx() + const first = invocation() + ctx.typert.register({ ...toolsContribution(), invocations: [first] }) + + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-endpoint', + descriptors: [invocation('@fixture/remote#first'), invocation('@fixture/remote#second')], + })).toThrow('endpoint "goals/create" is already registered') + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-id', + descriptors: [ + invocation('@fixture/remote#same'), + { ...invocation('@fixture/remote#same'), method: 'rename' }, + ], + })).toThrow('invocation id "@fixture/remote#same" is already registered') + expect(() => ctx.typert.register({ + ...toolsContribution(), + package: '@fixture/existing-endpoint', + invocations: [{ ...first, id: '@fixture/local#other' }], + })).toThrow('endpoint "goals/create" is already registered') + }) + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { const ctx = await makeCtx() const descriptor = invocation() @@ -314,6 +338,131 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { + const ctx = await makeCtx() + const changes: string[] = [] + const disposeLookupSubscription = ctx.typert.lookups.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const disposeContextSubscription = ctx.typert.contexts.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const lookup = { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const host = { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const client = { identity: () => undefined } + const disposeLookup = ctx.typert.lookups.register('fixture', lookup) + const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host) + const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client) + + expect(() => ctx.typert.lookups.register('fixture', lookup)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerHost('registryFixture', host)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerClient('registryFixture', client)).toThrow('already registered') + await Promise.all([disposeLookup(), disposeHost(), disposeClient()]) + expect(changes).toEqual([ + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + ]) + + await Promise.all([disposeLookupSubscription(), disposeContextSubscription()]) + ctx.typert.lookups.register('fixture', lookup) + expect(changes).toHaveLength(6) + }) + + it('validates every invocation and provider boundary', async () => { + const ctx = await makeCtx() + const strict = { + mode: 'strict' as const, + typeSymbol: '@fixture#Value', + schema: z.string(), + } + const strictInvocation: InvocationDescriptor = { + ...invocation('@fixture/remote#strict'), + implementation: 'remoteExportCreate', + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + result: strict, + } + const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) + await dispose() + + const malformed: readonly [InvocationDescriptor, string][] = [ + [{ ...invocation(), id: '' }, 'invocation id'], + [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], + [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + parameters: [ + ...invocation().parameters, + { name: 'other', wire: 'request', source: 'json', codec: { mode: 'src-json' } }, + ], + }, 'repeats wire field'], + [{ + ...invocation(), + parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }], + }, 'has no lookup key'], + [{ + ...invocation(), + parameters: [{ + name: 'request', wire: 'request', source: 'json', lookup: 'fixture', codec: { mode: 'src-json' }, + }], + }, 'JSON parameter'], + [{ + ...invocation(), + invocation: { + kind: 'context', context: 'registryFixture', wire: 'request', codec: { mode: 'src-json' }, + }, + }, 'repeats wire field'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '', schema: z.string() }, + }, 'type symbol'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '@fixture#Broken', schema: {} as z.ZodType }, + }, 'has no parse'], + ] + for (const [index, [descriptor, message]] of malformed.entries()) { + expect(() => ctx.typert.remotes.register({ + package: `@fixture/malformed-${String(index)}`, + descriptors: [descriptor], + })).toThrow(message) + } + + expect(() => ctx.typert.lookups.register('bad#key' as 'fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '', + wireTypeSymbol: '', + resolve: () => undefined, + })).toThrow('lookup key') + expect(() => ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + })).toThrow('lookup wire field') + }) + + it('installs the registry through the Client entry without importing the Host entry', async () => { + const ctx = new Context() + await ctx.plugin({ inject: clientRegistryInject, apply: applyClientRegistry }) + expect(ctx.typert.list()).toEqual([]) + }) + it('contains change-listener failures and still notifies later listeners', async () => { const ctx = await makeCtx() const warnings: unknown[] = [] diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index 2ffcd6c5ed..e2d7689866 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 1eab5a6ca3..f25c367914 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -107,6 +107,80 @@ describe('type-meta Remote declarations', () => { expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }]) }) + it('supports explicit export names without exposing marker storage', () => { + class Service { + run(value: string): string { + return value + } + + scoped(value: string): string { + return value + } + } + const initializers: Array<(this: Service) => void> = [] + Remote('execute')( + Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, + methodContext('run', initializers), + ) + RemoteContext('metaFixture', 'inspect')( + Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown, + methodContext('scoped', initializers), + ) + const service = new Service() + for (const initialize of initializers) initialize.call(service) + + expect(remoteMethods(service)).toEqual([ + { method: 'run', exportName: 'execute', invocation: { kind: 'direct' } }, + { method: 'scoped', exportName: 'inspect', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + expect(remoteMethods({})).toEqual([]) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(remoteMethods(prototypeLess)).toEqual([]) + }) + + it('rejects malformed decorator calls and targets', () => { + const method: (this: object) => void = function (this: object): void {} + expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') + expect(() => Remote('bad/name')).toThrow('export name') + expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') + expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') + + for (const context of [ + { ...methodContext('run', []), private: true }, + { ...methodContext('run', []), static: true }, + { ...methodContext('run', []), name: Symbol('run') }, + ]) { + expect(() => { Remote(method, context) }) + .toThrow('public instance method') + } + }) + + it('rejects prototype-less initialization and conflicting markers', () => { + const method: (this: object) => void = function (this: object): void {} + const direct: Array<(this: object) => void> = [] + Remote(method, methodContext('run', direct)) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype') + + class Service { + run(): void {} + } + const conflicting: Array<(this: Service) => void> = [] + Remote( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + RemoteContext('metaFixture')( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + const service = new Service() + conflicting[0]!.call(service) + expect(() => { conflicting[1]!.call(service) }).toThrow('conflicting invocation markers') + }) + it('rejects ambiguous binding names', () => { expect(() => bindTypeRTGateway({}, '')).toThrow('service key') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e8ad1803..caf1f8a5ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7077,6 +7077,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../packages/typert/type-meta '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/ui/user-approval diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 4fd7291633..d8151de3a4 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -93,6 +93,7 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..9be97f53e6 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -7,7 +7,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' -import { isForbiddenPublicationFile } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages// nests one level deeper @@ -122,6 +122,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] + const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest) return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate @@ -145,9 +146,37 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { // declarations. ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', + ...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js') + ? ['lib/typert.host.js', 'lib/typert.host.d.ts'] + : [], + ...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js') + ? ['lib/typert.client.js', 'lib/typert.client.d.ts'] + : [], + ...typeRTRemoteNavigation + ? [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + 'src', + ] + : [], ] } +/** Whether one conditional export exactly names the generated runtime and declaration pair. */ +function hasExportPair( + manifest: PackageManifest, + subpath: string, + types: string, + runtime: string, +): boolean { + const entry = manifest.exports?.[subpath] + return typeof entry === 'object' + && entry !== null + && entry.types === types + && entry.default === runtime +} + /** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */ function exportDefault(manifest: PackageManifest, subpath: string): string | undefined { const entry = manifest.exports?.[subpath] @@ -175,8 +204,9 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (manifest.name?.startsWith('@deepseek-ai/')) { + const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) } for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file)) { + if (isForbiddenPublicationFile(file, publicationPolicy)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 2edbc652ab..71576caf9e 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -22,13 +22,7 @@ export default defineConfig({ const bundlePath = join(root, 'lib/client.js') await writeFile(sourcePath, 'export const version = "watch-v1"\n') bundles = await watchClientPlugins(root, ['.'], 50) - await expect.poll(async () => { - try { - return (await readFile(bundlePath, 'utf8')).includes('watch-v1') - } catch { - return false - } - }, { timeout: 10_000 }).toBe(true) + expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1') await new Promise(resolve => setTimeout(resolve, 1_000)) await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index aee7146487..294e38002b 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -47,21 +47,40 @@ export function discoverPluginDirs(root = repoRoot): string[] { * @param root - repository or fixture root passed to tsdown. * @param pluginDirs - workspace-relative package directories to watch. * @param pollInterval - optional source-watcher polling interval in milliseconds. - * @returns live bundles whose async disposers stop every watcher. + * @returns live bundles after every watcher has completed its initial build. */ export async function watchClientPlugins( root: string, pluginDirs: readonly string[], pollInterval?: number, ): Promise { - return build({ + let resolveInitialBuilds: (() => void) | undefined + const initialBuilds = new Promise((resolve) => { resolveInitialBuilds = resolve }) + const initialized = new WeakSet() + const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 } + const bundles = await build({ cwd: root, workspace: [...pluginDirs], watch: true, + hooks: { + 'build:done': ({ options }) => { + if (initialized.has(options)) return + initialized.add(options) + readiness.initializedBuilds += 1 + if ( + readiness.expectedBuilds !== undefined + && readiness.initializedBuilds >= readiness.expectedBuilds + ) resolveInitialBuilds?.() + }, + }, ...pollInterval !== undefined ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } : {}, }) + readiness.expectedBuilds = bundles.length + if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.() + await initialBuilds + return bundles } const invokedPath = process.argv[1] diff --git a/scripts/publication-payload.spec.ts b/scripts/publication-payload.spec.ts index 0f403bee7c..03603ef54d 100644 --- a/scripts/publication-payload.spec.ts +++ b/scripts/publication-payload.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isForbiddenPublicationFile, validateTarballPayload } from './publication-payload.ts' +import { + hasTypeRTRemoteNavigation, + isForbiddenPublicationFile, + validateTarballPayload, +} from './publication-payload.ts' function validateFixtureTarball(files: readonly string[]): () => void { return () => { @@ -51,4 +55,29 @@ describe('publication payload policy', () => { 'package/lib/styles/base.css', ])).not.toThrow() }) + + it('allows only the TypeRT declaration map and its navigable source tree when requested', () => { + const policy = { typeRTRemoteNavigation: true } + expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true) + expect(() => { + validateTarballPayload([ + 'package/lib/typert.remote-client.d.ts.map', + 'package/src/index.ts', + ], 'fixture.tgz', policy) + }).not.toThrow() + }) + + it('recognizes only the canonical Host-for-Client export pair', () => { + expect(hasTypeRTRemoteNavigation({ + exports: { + './remote': { + types: './lib/typert.remote-client.d.ts', + default: './lib/typert.remote-client.js', + }, + }, + })).toBe(true) + expect(hasTypeRTRemoteNavigation({ exports: { './remote': './lib/remote.js' } })).toBe(false) + }) }) diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts index 9c16067fe7..60f37b4f94 100644 --- a/scripts/publication-payload.ts +++ b/scripts/publication-payload.ts @@ -1,5 +1,22 @@ /** Publication payload policy shared by static manifests and packed tarballs. */ +/** Publication exceptions required for TypeRT declaration-map navigation. */ +export interface PublicationPayloadPolicy { + readonly typeRTRemoteNavigation?: boolean +} + +/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */ +export function hasTypeRTRemoteNavigation(manifest: unknown): boolean { + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false + const exportsField = (manifest as Record).exports + if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false + const remote = (exportsField as Record)['./remote'] + if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false + const entry = remote as Record + return entry.types === './lib/typert.remote-client.d.ts' + && entry.default === './lib/typert.remote-client.js' +} + /** Normalize a package manifest path or npm tarball member to its payload-relative path. */ function payloadPath(file: string): string { const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '') @@ -7,17 +24,30 @@ function payloadPath(file: string): string { } /** Whether a package payload path exposes source or declaration-map intermediates. */ -export function isForbiddenPublicationFile(file: string): boolean { +export function isForbiddenPublicationFile( + file: string, + policy: PublicationPayloadPolicy = {}, +): boolean { const normalized = payloadPath(file) + if (policy.typeRTRemoteNavigation === true + && (normalized === 'src' + || normalized.startsWith('src/') + || normalized === 'lib/typert.remote-client.d.ts.map')) { + return false + } return normalized === 'src' || normalized.startsWith('src/') || normalized.endsWith('.d.ts.map') } /** Reject source and declaration-map members in a packed npm tarball. */ -export function validateTarballPayload(files: readonly string[], context: string): void { +export function validateTarballPayload( + files: readonly string[], + context: string, + policy: PublicationPayloadPolicy = {}, +): void { for (const file of files) { - if (!isForbiddenPublicationFile(file)) continue + if (!isForbiddenPublicationFile(file, policy)) continue const normalized = payloadPath(file) if (normalized === 'src' || normalized.startsWith('src/')) { throw new Error(`${context} publishes source file ${file}`) diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 20b9eeaea0..4a33f32e1e 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { validateTarballPayload } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' @@ -320,7 +320,11 @@ class ReleaseBundle { if (expected === undefined || !missingNames.delete(artifact.name)) { throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) } - if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball) + if (expected.origin === 'harness') { + validateTarballPayload(artifact.files, tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.version !== version) { throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) } @@ -394,7 +398,11 @@ class ReleaseBundle { throw new Error(`tarball checksum mismatch: ${pkg.tarball}`) } const artifact = inspectTarball(path, runner) - if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball) + if (pkg.origin === 'harness') { + validateTarballPayload(artifact.files, pkg.tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { throw new Error(`tarball identity mismatch: ${pkg.tarball}`) } From 41677c3be00557a2a741e03bbf6419956ca0c68e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:02:12 +0800 Subject: [PATCH 32/88] fix(ci): preserve TypeRT contract build order on Windows --- scripts/wine-windows-gates.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 7706a88a4b..1f5de1dcbd 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -204,11 +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 same shape run-gates gives ci-windows-blocking on native Windows: -# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both -# statuses are captured so one failure cannot hide the other's result. +# The build preserves the face order from package.json: generate Host contracts +# before either aggregate typecheck, then bundle the completed workspace. +# Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $? + 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/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? wine_node "$scratch/logs/tsdown.log" "$tsdown_js" } site_gate() { @@ -235,7 +238,12 @@ report() { for log in "$@"; do tail -n 200 "$log" >&2 || true; done fi } -report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log" +report 'build (contract prepass, tsc, tsdown)' "$build_status" \ + "$scratch/logs/contracts-tsc.log" \ + "$scratch/logs/contracts-tsdown.log" \ + "$scratch/logs/host-tsc.log" \ + "$scratch/logs/client-tsc.log" \ + "$scratch/logs/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" From 61c2c15dc46187ec44a8737781e38e656233352c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:02:10 +0800 Subject: [PATCH 33/88] refactor(goal): own direct goal operations --- packages/goal/goal/src/domain.ts | 29 +------------ packages/goal/goal/src/index.ts | 71 ++++++++++++++++++++++++++++++-- packages/goal/goal/src/types.ts | 32 ++++++++++++++ 3 files changed, 100 insertions(+), 32 deletions(-) diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index fec44de2f3..8c8c4de6b6 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -8,22 +8,7 @@ */ import type { Agent } from '@deepseek-ai/dsh-agent' -import type { GoalId, GoalRef, GoalSnapshot } from './types.ts' - -/** Whether this live process may automatically continue an active goal. */ -export type GoalActivation = 'armed' | 'disarmed' - -/** Current goal projection, including values derived from the session log. */ -export interface GoalView extends GoalSnapshot { - /** Highest admitted round number for this goal. */ - readonly roundsStarted: number - /** Epoch milliseconds of the create mutation. */ - readonly createdAt: number - /** Epoch milliseconds of the latest mutation. */ - readonly updatedAt: number - /** Process-local continuation eligibility; never persisted. */ - readonly activation: GoalActivation -} +import type { GoalId, GoalRef, GoalSnapshot, GoalView } from './types.ts' /** Goal state-changing verbs recorded in the durable source change. */ export type GoalOperation = @@ -96,18 +81,6 @@ export interface FoldedGoal { readonly lastRef?: GoalRef } -/** Input whose omitted round cap is resolved by the service configuration. */ -export interface CreateGoalRequest { - readonly objective: string - readonly maxGoalRounds?: number -} - -/** Fields changed by an edit; at least one must be present. */ -export interface EditGoalRequest { - readonly objective?: string - readonly maxGoalRounds?: number -} - /** Live notification after one durable goal mutation commits. */ export interface GoalChanged { readonly operation: GoalOperation diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 1cd3c6074a..87ea52018e 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -27,22 +27,23 @@ import { GoalId, } from './runtime.ts' import type { + CreateGoalRequest, + CreateGoalResult, + EditGoalRequest, + GoalActivation, GoalBlockReason, GoalPhase, GoalProjection, GoalRef, GoalSnapshot, + GoalView, } from './types.ts' import type { - CreateGoalRequest, - EditGoalRequest, - GoalActivation, GoalChangeMeta, GoalChanged, GoalClearChangeMeta, GoalOperation, GoalSnapshotChangeMeta, - GoalView, } from './domain.ts' // The pure payload outlet (./types.ts, ONE home of the `goal` projection-key @@ -568,6 +569,68 @@ export class GoalService extends Service { activation: cache.activation, } } + + /** + * Create one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param request - objective and optional round cap. + * @returns the created Goal identity. + */ + remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } + } + + /** + * Edit one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @param request - replacement fields. + * @returns the edited Goal view. + */ + remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { + return this.edit(agent, ref, request) + } + + /** + * Pause one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the paused Goal view. + */ + remoteExportPause(agent: Agent, ref: GoalRef): GoalView { + return this.pause(agent, ref) + } + + /** + * Resume one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the resumed Goal view. + */ + remoteExportResume(agent: Agent, ref: GoalRef): GoalView { + return this.resume(agent, ref) + } + + /** + * Complete one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the completed Goal view. + */ + remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { + return this.complete(agent, ref) + } + + /** + * Clear one terminal Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the committed clear revision. + */ + remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { + return this.clear(agent, ref) + } } export default GoalService diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 25e22bd5b2..f277e8620c 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -23,6 +23,23 @@ export interface GoalRef { readonly revision: number } +/** Input whose omitted round cap is resolved by the service configuration. */ +export interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} + +/** Wire-safe acknowledgement of one created goal. */ +export interface CreateGoalResult { + readonly ref: GoalRef +} + +/** Fields changed by an edit; at least one must be present. */ +export interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} + /** Durable continuation phase. Activation is process-local and separate. */ export type GoalPhase = | 'active' @@ -50,6 +67,21 @@ export interface GoalSnapshot extends GoalRef { readonly maxGoalRounds: number } +/** Whether this live process may automatically continue an active goal. */ +export type GoalActivation = 'armed' | 'disarmed' + +/** Current goal projection, including values derived from the session log. */ +export interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} + /** * The `goal` projection value: the current durable goal with its replay * counters, exactly as the latest `goal/change` event carried them. From 9400926bdfe8320726e64664a36a3cbde6b21b59 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:28:54 +0800 Subject: [PATCH 34/88] feat(goal): add TypeRT gateway example --- docs/config-catalog.md | 3 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 53 ++- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 312 +++++++++++------- docs/persistence-catalog.md | 2 +- knip.json | 9 + packages/bundle/web-app/cordis.patch.yml | 3 + packages/bundle/web-app/package.json | 1 + packages/client/remotes/README.i18n.yaml | 6 + packages/client/remotes/README.md | 22 ++ packages/client/remotes/README.zh.md | 22 ++ packages/client/remotes/package.json | 55 +++ packages/client/remotes/src/client/index.ts | 19 ++ packages/client/remotes/src/index.ts | 4 + packages/client/remotes/src/invariant.ts | 24 ++ .../client/remotes/tests/built-lib.e2e.ts | 214 ++++++++++++ packages/client/remotes/tsconfig.json | 30 ++ packages/client/remotes/tsdown.config.ts | 3 + packages/client/runtime/package.json | 10 +- .../client/runtime/src/client/agents/scope.ts | 18 +- .../runtime/src/client/contract/sessions.ts | 6 +- packages/client/runtime/src/client/index.ts | 18 +- .../runtime/src/client/sessions/service.ts | 8 +- .../client/runtime/tests/client-apply.spec.ts | 3 + packages/client/runtime/tsconfig.json | 9 + packages/client/test-runtime/src/sessions.ts | 8 +- .../cordis/tool-cordis/src/api-catalog.ts | 28 ++ packages/goal/goal/package.json | 18 +- packages/goal/goal/src/index.ts | 10 + packages/goal/goal/tsconfig.json | 3 + pnpm-lock.yaml | 30 ++ scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 6 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.client.json | 1 + 36 files changed, 809 insertions(+), 155 deletions(-) create mode 100644 packages/client/remotes/README.i18n.yaml create mode 100644 packages/client/remotes/README.md create mode 100644 packages/client/remotes/README.zh.md create mode 100644 packages/client/remotes/package.json create mode 100644 packages/client/remotes/src/client/index.ts create mode 100644 packages/client/remotes/src/index.ts create mode 100644 packages/client/remotes/src/invariant.ts create mode 100644 packages/client/remotes/tests/built-lib.e2e.ts create mode 100644 packages/client/remotes/tsconfig.json create mode 100644 packages/client/remotes/tsdown.config.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5728ac4bed..08f26479ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -495,7 +495,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:114`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-headless` @@ -2522,6 +2522,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-remotes` ([`packages/client/remotes/src/index.ts`](../packages/client/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 348d334e9f..4ad9797262 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -472,7 +472,7 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/domain.ts:141`](../../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:114`](../../packages/goal/goal/src/domain.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 41059aebf4..9f5e66ea36 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -761,11 +761,60 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView * @returns the tombstone ref whose revision is one past the cleared snapshot. */ clear(agent: Agent, ref: GoalRef): GoalRef + +/** + * Create one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param request - objective and optional round cap. + * @returns the created Goal identity. + */ +@Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult + +/** + * Edit one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @param request - replacement fields. + * @returns the edited Goal view. + */ +@Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView + +/** + * Pause one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the paused Goal view. + */ +@Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView + +/** + * Resume one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the resumed Goal view. + */ +@Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView + +/** + * Complete one Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the completed Goal view. + */ +@Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView + +/** + * Clear one terminal Goal through the remote boundary. + * @param agent - exact live Agent resolved from the wire identity. + * @param ref - expected current revision. + * @returns the committed clear revision. + */ +@Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef ``` -Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) +Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalResult](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:181`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) ## `ctx.httpServer` — `HttpServerService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 286c0ee4c2..f5b3a0b99a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/module-graph.md b/docs/module-graph.md index fd9ac036a0..f659e61206 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -156,6 +156,7 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] + pkg_client_remotes["client-remotes"] pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] @@ -300,7 +301,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants - pkg_client_runtime --> pkg_invariants pkg_client_schema_form --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_slots --> pkg_invariants @@ -324,22 +324,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - 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_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 --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -383,43 +367,6 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - 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_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_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants @@ -472,24 +419,17 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt +<<<<<<< HEAD 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 +======= +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - 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_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -518,6 +458,7 @@ flowchart TD pkg_goal --> pkg_scope pkg_goal --> pkg_session pkg_goal --> pkg_session_projection + pkg_goal --> pkg_type_meta pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess @@ -582,10 +523,6 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> 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 --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -677,6 +614,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval +<<<<<<< HEAD pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -687,6 +625,11 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session +======= + pkg_client_remotes --> pkg_goal + pkg_client_remotes --> pkg_host_api_gateway + pkg_client_remotes --> pkg_invariants +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -840,6 +783,7 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction +<<<<<<< HEAD pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -869,6 +813,12 @@ flowchart TD pkg_client_ui_skill --> pkg_client_ui_slash pkg_client_ui_skill --> pkg_client_ui_slots pkg_client_ui_skill --> pkg_invariants +======= + pkg_client_runtime --> pkg_client_remotes + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1000,42 +950,29 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - 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_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_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1078,6 +1015,36 @@ 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 @@ -1111,6 +1078,22 @@ 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_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1131,6 +1114,72 @@ 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_goal --> pkg_client_connection + 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_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 | @@ -1149,7 +1198,6 @@ flowchart TD | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | @@ -1168,10 +1216,6 @@ 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-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-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`](../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) | @@ -1187,13 +1231,6 @@ flowchart TD | [`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) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`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-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) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1210,16 +1247,17 @@ flowchart TD | [`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) | +<<<<<<< HEAD | [`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) | +======= +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`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) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-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) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1237,7 +1275,6 @@ flowchart TD | [`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) | | [`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-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`](../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) | @@ -1257,8 +1294,12 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`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) | +<<<<<<< HEAD | [`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) | +======= +| [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`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) | @@ -1284,10 +1325,14 @@ 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) | +<<<<<<< HEAD | [`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` | [`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), [`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) | +======= +| [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | +>>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`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) | @@ -1309,10 +1354,11 @@ 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-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) | +| [`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) | | [`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) | @@ -1320,8 +1366,26 @@ 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) | +| [`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) | | [`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-goal`](../packages/client/ui-goal) | `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), [`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-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/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3c037198da..48732dbbb0 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -340,7 +340,7 @@ Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedb 'goal/change': GoalChangeMeta ``` -Source: [`packages/goal/goal/src/domain.ts:81`](../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts:66`](../packages/goal/goal/src/domain.ts) ### `hook/*` diff --git a/knip.json b/knip.json index 32c9e20dbf..3ce9a32d99 100644 --- a/knip.json +++ b/knip.json @@ -115,6 +115,15 @@ "tests/**/*.ts" ] }, + "packages/client/remotes": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/client/ui-primitives": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 681f0d5121..001c43948d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -124,6 +124,9 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' + - id: client-remotes + name: '@deepseek-ai/dsh-client-remotes' + - id: client-runtime name: '@deepseek-ai/dsh-client-runtime' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 29eeb24009..89b5e8e2a7 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/remotes/README.i18n.yaml b/packages/client/remotes/README.i18n.yaml new file mode 100644 index 0000000000..86f2aded18 --- /dev/null +++ b/packages/client/remotes/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/client/remotes/README.md +README.md: e29188b8e3ae5ecefe194f1355558e9bdeaae7dd +README.zh.md: e6425ab190a28e0a38c3713c4e21645789a8f00c diff --git a/packages/client/remotes/README.md b/packages/client/remotes/README.md new file mode 100644 index 0000000000..e29188b8e3 --- /dev/null +++ b/packages/client/remotes/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-remotes + +English | [中文](README.zh.md) + +Platform-neutral Client facade for Host Remote capabilities selected by this application. Its Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Host API Gateway or individual Remote runtime entries. + +The current assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while the Client face of `@deepseek-ai/dsh-host-api-gateway` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. + +This package contains no transport or Host discovery logic. It can be reused by Web or a future TUI Client that provides the same React-free `ctx.api` contract. + +## Model Experience + +None, as this Client assembly selects Remote application methods and registers no model surface. + +#### KV Cache effect + +No direct effect; mounted Host capabilities own any model-visible behavior they trigger. + +## Known Limitations and Deferred Work + +- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. +- Additional capabilities require an explicit `/remote` value import and mount in this assembly. diff --git a/packages/client/remotes/README.zh.md b/packages/client/remotes/README.zh.md new file mode 100644 index 0000000000..e6425ab190 --- /dev/null +++ b/packages/client/remotes/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-client-remotes + +[English](README.md) | 中文 + +为本应用选定的 Host Remote 能力提供平台无关的 Client 外观。其 Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖此外观,而不依赖 Host API Gateway 或单独的 Remote 运行时入口。 + +当前组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-host-api-gateway` 的 Client 侧负责描述符校验、具体的根级方法和作用域方法、调用与取消。 + +本包不包含传输逻辑或 Host 发现逻辑。Web 和未来的 TUI Client 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用本包。 + +## 模型体验 + +无,因为此 Client 组合只选择应用的 Remote 方法,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 + +## 已知限制与暂缓事项 + +- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 +- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 diff --git a/packages/client/remotes/package.json b/packages/client/remotes/package.json new file mode 100644 index 0000000000..ba4e7b6a01 --- /dev/null +++ b/packages/client/remotes/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-client-remotes", + "description": "Platform-neutral assembly of explicitly selected Host Remote contributions", + "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" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-host-api-gateway" + ], + "platform": "web", + "immediately": true + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/dsh-host-api-gateway": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/client/remotes/src/client/index.ts b/packages/client/remotes/src/client/index.ts new file mode 100644 index 0000000000..09757b5e9e --- /dev/null +++ b/packages/client/remotes/src/client/index.ts @@ -0,0 +1,19 @@ +/** Platform-neutral assembly of generated Host Remote contributions. */ + +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-host-api-gateway/client' +import goalsRemote from '@deepseek-ai/dsh-goal/remote' + +export type { ClientApi } from '@deepseek-ai/dsh-host-api-gateway/client' +export type {} from '@deepseek-ai/dsh-goal/remote' + +/** Required service: the typed Client API contribution mount. */ +export const inject = ['api'] + +/** + * Mount the Host capabilities explicitly selected for this Client assembly. + * @param ctx - Client Cordis root carrying the typed API service. + */ +export function apply(ctx: Context): void { + ctx.api.mount(goalsRemote) +} diff --git a/packages/client/remotes/src/index.ts b/packages/client/remotes/src/index.ts new file mode 100644 index 0000000000..c8c4ff20be --- /dev/null +++ b/packages/client/remotes/src/index.ts @@ -0,0 +1,4 @@ +/** Host Loader entry for the Client Remote contribution assembly. */ + +/** Host plugin body; the selected contributions mount only in Client environments. */ +export function apply(): void {} diff --git a/packages/client/remotes/src/invariant.ts b/packages/client/remotes/src/invariant.ts new file mode 100644 index 0000000000..1a6b0ba237 --- /dev/null +++ b/packages/client/remotes/src/invariant.ts @@ -0,0 +1,24 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-client-remotes`. */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-remotes' + +/** Cordis companion plugin name. */ +export const name = 'client-remotes-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: the API service owns contribution and method lifecycle atomically. */ +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/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..bbba218844 --- /dev/null +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -0,0 +1,214 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Built-artifact smoke for the first generated Remote: plain Node boots the + * Host and Browser bundle handoffs, then crosses the real `/api2` HTTP route. + */ + +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const root = resolve(packageDir, '../../..') +const artifact = (path: string): string => join(root, path) +const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href + +const requiredArtifacts = [ + 'packages/client/connection/lib/client.js', + 'packages/client/connection/lib/index.js', + 'packages/client/remotes/lib/client.js', + 'packages/core/agent/lib/index.js', + 'packages/core/session/lib/index.js', + 'packages/goal/goal/lib/index.js', + 'packages/goal/goal/lib/typert.host.js', + 'packages/host/api-gateway/lib/client.js', + 'packages/host/api-gateway/lib/index.js', + 'packages/typert/registry/lib/client.js', + 'packages/typert/registry/lib/index.js', +].every(path => existsSync(artifact(path))) + +describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { + it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => { + const urls = Object.fromEntries(Object.entries({ + agent: 'packages/core/agent/lib/index.js', + apiGatewayClient: 'packages/host/api-gateway/lib/client.js', + apiGatewayHost: 'packages/host/api-gateway/lib/index.js', + connectionClient: 'packages/client/connection/lib/client.js', + connectionHost: 'packages/client/connection/lib/index.js', + goal: 'packages/goal/goal/lib/index.js', + goalTypert: 'packages/goal/goal/lib/typert.host.js', + registryClient: 'packages/typert/registry/lib/client.js', + registryHost: 'packages/typert/registry/lib/index.js', + remotesClient: 'packages/client/remotes/lib/client.js', + session: 'packages/core/session/lib/index.js', + }).map(([key, path]) => [key, artifactUrl(path)])) + const script = ` + import { createServer } from 'node:http' + import * as cordis from 'cordis' + + const urls = ${JSON.stringify(urls)} + const { Context } = cordis + const { default: AgentRegistry } = await import(urls.agent) + const connectionHost = await import(urls.connectionHost) + const { default: TypertGatewayService } = await import(urls.apiGatewayHost) + const { default: GoalService } = await import(urls.goal) + const { TYPERT } = await import(urls.goalTypert) + const { default: TypertRegistry } = await import(urls.registryHost) + const { Session, SessionId } = await import(urls.session) + + const routes = [] + const host = new Context() + host.provide('httpServer', { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex() { return () => {} }, + port: 0, + }) + await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) + await host.plugin(TypertRegistry) + await host.plugin(AgentRegistry) + await host.plugin(TypertGatewayService) + await host.plugin(GoalService) + host.typert.register(TYPERT) + + const makeAgent = rawId => { + const session = new Session(SessionId(rawId)) + return { + id: session.id, + options: {}, + session, + ctx: host.extend(), + status: 'idle', + acceptsNextStep: false, + send() {}, + updateInbox() { return 'not-found' }, + followup() {}, + steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } }, + inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) }, + reserveTurnAdmission() {}, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + } + const rootAgent = makeAgent('built-root-agent') + const scopedAgent = makeAgent('built-scoped-agent') + host.agents.register(rootAgent) + host.agents.register(scopedAgent) + + if (routes.length !== 1) throw new Error('Gateway did not register exactly one /api2 route') + const server = createServer((request, response) => { void routes[0].handler(request, response) }) + await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address') + const origin = 'http://127.0.0.1:' + String(address.port) + + const handoffs = new Map() + globalThis.window = { + __ModuleLoader__: { + load(handoff) { handoffs.set(handoff.id, handoff) }, + }, + } + globalThis.location = { hostname: '127.0.0.1', origin, search: '' } + await import(urls.registryClient) + await import(urls.connectionClient) + await import(urls.apiGatewayClient) + await import(urls.remotesClient) + + const instantiate = id => { + const handoff = handoffs.get(id) + if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) + return handoff.factory(specifier => { + if (specifier === 'cordis') return cordis + throw new Error('unexpected Client external ' + specifier) + }) + } + const client = new Context() + for (const id of [ + '@deepseek-ai/dsh-typert-registry', + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-host-api-gateway', + '@deepseek-ai/dsh-client-remotes', + ]) { + const plugin = instantiate(id) + await client.plugin({ inject: plugin.inject, apply: plugin.apply }) + } + client.typert.contexts.registerClient('agent', { + identity: candidate => candidate.builtAgentId, + }) + + let invalidRejected = false + try { + await client.api.goals.create(rootAgent.id, { objective: 1 }) + } catch { + invalidRejected = true + } + const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) + const agentContext = client.extend({ builtAgentId: scopedAgent.id }) + const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) + const result = { + invalidRejected, + rootResult, + scopedResult, + rootGoal: host.goals.get(rootAgent)?.objective, + scopedGoal: host.goals.get(scopedAgent)?.objective, + rootEvents: rootAgent.session.events.length, + scopedEvents: scopedAgent.session.events.length, + } + + await client.fiber.dispose() + await new Promise((resolveClose, rejectClose) => server.close(error => { + if (error === undefined) resolveClose() + else rejectClose(error) + })) + await host.fiber.dispose() + console.log(JSON.stringify(result)) + ` + + const result = await runPlainNode(script) + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) + const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { + invalidRejected: boolean + rootResult: { ref: { id: string; revision: number } } + scopedResult: { ref: { id: string; revision: number } } + rootGoal: string + scopedGoal: string + rootEvents: number + scopedEvents: number + } + expect(output).toMatchObject({ + invalidRejected: true, + rootResult: { ref: { revision: 1 } }, + scopedResult: { ref: { revision: 1 } }, + rootGoal: 'root goal', + scopedGoal: 'scoped goal', + rootEvents: 1, + scopedEvents: 1, + }) + expect(output.rootResult.ref.id).toMatch(/^goal-/) + expect(output.scopedResult.ref.id).toMatch(/^goal-/) + }, 60_000) +}) + +/** Execute one ESM script without tsx or a TypeScript loader. */ +function runPlainNode(script: string): Promise<{ + readonly exitCode: number | null + readonly stdout: string + readonly stderr: string +}> { + return new Promise((resolveRun) => { + execFile(process.execPath, ['--input-type=module', '-e', script], { + cwd: packageDir, + encoding: 'utf8', + timeout: 55_000, + }, (error, stdout, stderr) => { + resolveRun({ + exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null, + stdout, + stderr, + }) + }) + }) +} diff --git a/packages/client/remotes/tsconfig.json b/packages/client/remotes/tsconfig.json new file mode 100644 index 0000000000..c99a5fce19 --- /dev/null +++ b/packages/client/remotes/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../host/api-gateway" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../session-title/session-title" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/remotes/tsdown.config.ts b/packages/client/remotes/tsdown.config.ts new file mode 100644 index 0000000000..20fa098462 --- /dev/null +++ b/packages/client/remotes/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index b636316b68..cc51aa772d 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -24,7 +24,9 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-connection" + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-typert-registry" ], "platform": "web", "immediately": true @@ -47,11 +49,17 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-client-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-client-remotes": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index af6fa3afcd..ba4fd8ede7 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,6 +18,7 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -29,7 +30,7 @@ export interface AgentScopeHandle { * through it (passing it as the dispatch subject routes to this agent's * tagged listeners plus every untagged one). */ - ctx: Context + ctx: Context & TypeRTRemoteContextApi<'agent'> /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -48,15 +49,16 @@ function agentScope(): void {} */ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { const fiber = ctx.plugin(agentScope) + const scoped = fiber.ctx.extend({ + [kScope]: key, + [CordisContext.filter](listenerCtx: Context): boolean { + const tag = scopeOf(listenerCtx) + return tag === undefined || tag === key + }, + }) as Context & TypeRTRemoteContextApi<'agent'> return { fiber, - ctx: fiber.ctx.extend({ - [kScope]: key, - [CordisContext.filter](listenerCtx: Context): boolean { - const tag = scopeOf(listenerCtx) - return tag === undefined || tag === key - }, - }), + ctx: scoped, } } diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index fbb0bb1a4e..8e9c530720 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -11,6 +11,7 @@ import type { Context } from 'cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' +import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { @@ -19,6 +20,9 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' +/** Client Cordis Context carrying one Agent identity and its generated Remote namespaces. */ +export type AgentContext = Context & TypeRTRemoteContextApi<'agent'> + /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */ @@ -95,7 +99,7 @@ export interface ISessions { * @param id - session id. * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ - scope(id: SessionId): Context | undefined + scope(id: SessionId): AgentContext | undefined /** * Read the Agent scope tag off a context (service-method seam: fetch * bundles must reach scope resolution through ctx.sessions). diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 06f88a9131..f1efd6a65d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,6 +1,8 @@ /** 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-client-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' import { SessionsService } from './sessions/service.ts' @@ -26,7 +28,7 @@ export type { ISession, ProjectionsFace, SessionFace } from './contract/session. export type { ISessionHistory, SessionHistoryFace, SessionHistorySnapshot, } from './contract/session-history.ts' -export type { ISessions } from './contract/sessions.ts' +export type { AgentContext, ISessions } from './contract/sessions.ts' export type { IWorkspaces } from './contract/workspaces.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, @@ -75,6 +77,13 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Client-side Cordis context after declaration merging. */ export type ClientContext = Context +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTContextMap { + /** Client Agent scope identity; the agent and session share one wire id. */ + agent: TypeRTContext + } +} + /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ export type UseConversationSession = SnapshotSelectorHook @@ -170,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the wire handle mounted by the connection plugin. */ -export const inject = ['connection'] +/** Required services: the typed Remote API, wire handle, and Client TypeRT registry. */ +export const inject = ['api', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. @@ -180,6 +189,9 @@ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => sessions.scopeOf(candidate), + }) const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b1b271e702..621760df02 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -29,7 +29,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' -import type { ISessions } from '../contract/sessions.ts' +import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' @@ -127,7 +127,7 @@ export interface SessionBinding { readonly sessionId: SessionId /** The outward session face only — feature code never sees the concrete class. */ readonly session: SessionFace - readonly ctx: Context + readonly ctx: AgentContext } // Scope primitives live in ../agents/scope.ts (the client mirror of host @@ -182,7 +182,7 @@ function increasedForkTitle(title: string): string { interface ScopeRecord { fiber: Fiber - ctx: Context + ctx: AgentContext binding: SessionBinding /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */ session: Session @@ -483,7 +483,7 @@ export class SessionsService implements ISessions { * @param id - session id (the agent identity — 1:1 same axis). * @returns scoped ctx, or undefined for a session neither listed nor already scoped. */ - scope(id: SessionId): Context | undefined { + scope(id: SessionId): AgentContext | undefined { return this.resolve(id)?.ctx } diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 14e51fae8e..5635793122 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' @@ -22,6 +23,7 @@ interface Bench { async function mount(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { @@ -36,6 +38,7 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('api', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index f1512c7059..85ba61d41a 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../connection" }, + { + "path": "../remotes" + }, { "path": "../../host/apiproxy" }, @@ -43,6 +46,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" } ], "exclude": [ diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index e313b63fd2..4747929572 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -3,7 +3,7 @@ import type { Context } from 'cordis' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, + AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, SubagentAddress, } from '@deepseek-ai/dsh-client-runtime/client' @@ -134,7 +134,7 @@ interface SessionRecord { summary: SessionSummary snapshot: SnapshotStore session: FixtureSession - scope: Context | undefined + scope: AgentContext | undefined scopeFiber: { dispose(): Promise } | undefined /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */ provideInfo: SessionProvideInfo | undefined @@ -144,7 +144,7 @@ interface SessionRecord { export interface TestSessionBinding { readonly sessionId: SessionId readonly session: FixtureSession - readonly ctx: Context + readonly ctx: AgentContext } /** @@ -345,7 +345,7 @@ export class TestSessions implements ISessions { * @param id - session id. * @returns the scoped context, or undefined for unknown sessions. */ - scope(id: string): Context | undefined { + scope(id: string): AgentContext | undefined { const record = this.records.get(id as SessionId) if (record === undefined) return undefined if (record.scope === undefined) { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b8da6049e8..48f4aadeed 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -382,6 +382,30 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', }, + { + signature: '@Remote(\'create\') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult', + jsDoc: '/**\n * Create one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param request - objective and optional round cap.\n * @returns the created Goal identity.\n */', + }, + { + signature: '@Remote(\'edit\') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + jsDoc: '/**\n * Edit one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @param request - replacement fields.\n * @returns the edited Goal view.\n */', + }, + { + signature: '@Remote(\'pause\') remoteExportPause(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Pause one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the paused Goal view.\n */', + }, + { + signature: '@Remote(\'resume\') remoteExportResume(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Resume one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the resumed Goal view.\n */', + }, + { + signature: '@Remote(\'complete\') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Complete one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the completed Goal view.\n */', + }, + { + signature: '@Remote(\'clear\') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef', + jsDoc: '/**\n * Clear one terminal Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the committed clear revision.\n */', + }, ], }, { @@ -1859,6 +1883,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateGoalRequest', declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'CreateGoalResult', + declaration: 'export interface CreateGoalResult {\n readonly ref: GoalRef;\n}', + }, { name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 397e5717ba..fccf7de3be 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -23,6 +23,14 @@ "types": "./lib/types/client.d.ts", "default": "./lib/types/client.js" }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -30,7 +38,13 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" ], "license": "BSD-3-Clause", "peerDependencies": { @@ -41,6 +55,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-type-meta": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -56,6 +71,7 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 87ea52018e..0997aad0dc 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -12,6 +12,7 @@ import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { Remote, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { @@ -189,6 +190,9 @@ export class GoalService extends Service { private readonly resolved: ResolvedConfig private readonly caches = new WeakMap() + /** Explicit participation in the TypeRT Gateway under the Cordis service key. */ + readonly typertGateway = bindTypeRTGateway(this, 'goals') + constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { @@ -576,6 +580,7 @@ export class GoalService extends Service { * @param request - objective and optional round cap. * @returns the created Goal identity. */ + @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { const view = this.create(agent, request) return { ref: { id: view.id, revision: view.revision } } @@ -588,6 +593,7 @@ export class GoalService extends Service { * @param request - replacement fields. * @returns the edited Goal view. */ + @Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { return this.edit(agent, ref, request) } @@ -598,6 +604,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the paused Goal view. */ + @Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView { return this.pause(agent, ref) } @@ -608,6 +615,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the resumed Goal view. */ + @Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView { return this.resume(agent, ref) } @@ -618,6 +626,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the completed Goal view. */ + @Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { return this.complete(agent, ref) } @@ -628,6 +637,7 @@ export class GoalService extends Service { * @param ref - expected current revision. * @returns the committed clear revision. */ + @Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { return this.clear(agent, ref) } diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json index 9663f894fe..f106707bd3 100644 --- a/packages/goal/goal/tsconfig.json +++ b/packages/goal/goal/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../typert/type-meta" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caf1f8a5ba..d9a4453a61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1131,6 +1131,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../../client/remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -1345,6 +1348,21 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/client/remotes: + devDependencies: + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-host-api-gateway': + specifier: workspace:^ + version: link:../../host/api-gateway + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/runtime: dependencies: '@deepseek-ai/dsh-client-connection': @@ -1387,12 +1405,21 @@ 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-client-remotes': + specifier: workspace:^ + version: link:../remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3509,6 +3536,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 84013225f7..088329e83d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -90,6 +90,7 @@ export const LINK_MAP: Readonly> = { FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', CreateGoalRequest: 'goal.md', + CreateGoalResult: 'goal.md', EditGoalRequest: 'goal.md', GoalBlockReason: 'goal.md', GoalChanged: 'goal.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 603ad20d8e..84b957e633 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -199,7 +199,7 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", @@ -219,12 +219,12 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", - "source": "packages/goal/goal/src/domain.ts" + "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7e81b30e07..78745dbed1 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/remotes': { kind: 'none', reason: 'Client-side Remote assembly; selected business methods own any model-visible effect.' }, 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.client.json b/tsconfig.client.json index b0567f762e..327b337963 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -53,6 +53,7 @@ { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, { "path": "./packages/host/api-gateway" }, + { "path": "./packages/client/remotes" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, From 4eff7510589a1661e114a7cef28b8db1733abe6b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:13:45 +0800 Subject: [PATCH 35/88] test(client): mount TypeRT remote assembly in fixtures --- apps/web/tests/assembled-boot.ts | 31 ++++++++++--------- apps/web/tests/search-card.snapshot.ts | 4 +-- .../client/runtime/tests/wire-events.spec.ts | 3 ++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0e168ba9fe..ebb2aa513a 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -1,5 +1,5 @@ // Shared scaffolding for the assembled-jsdom snapshots: the real built -// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's +// workspace `lib/client.js` artifacts booted through AppWebEntry's // ModuleLoader path (loadBundle) against the keyless FixtureApiClient // transport. Every file that mounts this graph needs the same boot entry list, // the same bundle map, the same jsdom globals, and the same mount call, and @@ -14,18 +14,21 @@ import { afterEach, beforeEach, vi } from 'vitest' import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' import { AppWebEntry } from '@deepseek-ai/dsh-client-web' -/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */ -const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, +/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */ +const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ + { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true }, + { 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-host-api-gateway', bundlePath: 'packages/host/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-client-remotes', bundlePath: 'packages/client/remotes/lib/client.js', url: '/plugins/client-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-host-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-client-remotes', '@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'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', - dir: 'ui-workspace', + bundlePath: 'packages/client/ui-workspace/lib/client.js', url: '/plugins/ui-workspace.js', rev: 'fx', inject: [ @@ -34,12 +37,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ '@deepseek-ai/dsh-client-ui-sidebar', ], }, - { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] const bundles = new Map(PLUGINS.map(plugin => [ plugin.url, - readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), + readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'), ])) interface FixtureWindow extends Window { @@ -97,7 +100,7 @@ export function mountAssembledApp(): void { const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } act(() => { const entry = new AppWebEntry(root, { loadBundle: async (url) => { diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 626be993a6..8e6322c4af 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -// Assembled search-card snapshot: boots the real built `packages/client/*/lib/ -// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless +// Assembled search-card snapshot: boots the real built workspace client bundles +// through AppWebEntry's ModuleLoader path against the keyless // FixtureApiClient transport (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 66) renders in // the assembled application. The built-boot smoke proves the graph boots but diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index f081eb54c1..5ab644682a 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import { FakeApiClient } from './fake-api.ts' @@ -16,6 +17,7 @@ interface Bench { async function mount(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { @@ -30,6 +32,7 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) + ctx.reflect.provide('api', {}) await ctx.plugin(RuntimeClient).await() return bench } From 36516e97b970c47b15504f6825d891b1f21bf864 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:41:24 +0800 Subject: [PATCH 36/88] feat(connection): dispatch TypeRT remotes through shared API --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 73 ++++++----- ...026-08-02-typert-remote-method-calls.zh.md | 73 ++++++----- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 9 +- packages/client/connection/src/http-bridge.ts | 8 +- packages/client/connection/src/index.ts | 66 +++++----- packages/client/connection/src/rpc-host.ts | 74 ++++++++++- packages/client/connection/src/rpc.ts | 22 +++- .../connection/tests/client-apply.spec.ts | 28 ++--- .../client/connection/tests/node-half.spec.ts | 118 +++++++++++++++--- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 4 +- packages/host/api-gateway/README.zh.md | 4 +- packages/host/api-gateway/src/client/index.ts | 2 +- packages/host/api-gateway/src/index.ts | 45 ++++--- .../host/api-gateway/tests/client.spec.ts | 6 +- .../host/api-gateway/tests/gateway.spec.ts | 73 +++++++++-- 20 files changed, 439 insertions(+), 182 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index cc2f0736d4..6e7a1a3a13 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/proposed/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c3a7a77c583720c3f967de185a089d374f017d81 -2026-08-02-typert-remote-method-calls.zh.md: 9b2fbbd69f1c054cbf6c86f177b743c583be3e8a +2026-08-02-typert-remote-method-calls.md: 61c8f61468621846fa8e8ff78d52313ae805aa17 +2026-08-02-typert-remote-method-calls.zh.md: 1e09965d2baba2db35301288f338cef15d947f36 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md index c3a7a77c58..61c8f61468 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md @@ -20,7 +20,7 @@ A business Service declares callable methods with `@Remote` or `@RemoteContext() The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over the single Connection/RPC mechanism through an isolated `/api2` channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. ## Components and Cordis services @@ -30,7 +30,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, RPC envelope, rpcId, serialization, trust, and error transport, while carrying the isolated `/api` and `/api2` channels | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | | Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | | Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | @@ -139,7 +139,7 @@ Parameter order comes from the method signature. HTTP fields come from parameter A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. -Descriptors exist only in the local registry on each side. The wire carries only the `/api2` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. +Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result. ## TypeRT runtime registry @@ -294,20 +294,20 @@ Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client` `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api2', endpoint, { args })`. +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args })`. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api2` call. +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text root ctx.api.goals.create(agentId, request) → direct descriptor - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) agent.goals.create(request) → tracker 将 namespace Service rebind 到 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. @@ -318,7 +318,7 @@ Generated Remote JS contains only descriptors, symbol keys, and codecs; it does Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. -Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api2` RPC calls. +Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. @@ -363,22 +363,28 @@ ctx.typertGateway.invoke({ namespace, method, args }) `ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport. -## The `/api2` call chain +## The shared `/api` call chain -`/api2` is an isolated protocol channel on the single Connection/RPC mechanism, not a transport created by the Gateway. The Gateway registers one local handler with Connection. This phase adds the following general channel capability to the existing HTTP Connection: +Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchronous endpoint ownership test and the Remote RPC handler into Connection: ```text -ctx.connection.rpc.handle('/api2', (endpoint, payload) => { - const { namespace, method } = parseEndpoint(endpoint) - const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) -}) +ctx.connection.rpc.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) + }, +) ``` -The Connection Host half obtains a handle from the single HTTP Server and reuses the same RPC bridge, request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. Its current physical mapping is: +The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; only an endpoint that is not Remote-owned reaches the legacy API Proxy fallback. + +The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler selects either the Gateway RPC FetchHandler or the API Proxy FetchHandler. Both paths reuse the same request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. The current physical mapping is: ```text -POST /api2// +POST /api// ``` The Remote payload is a named JSON object, not a positional array, and does not carry an `InvocationDescriptor`. A normal Goal call has this payload slot: @@ -399,11 +405,12 @@ The complete path is: ```text ctx.api.goals.create(sessionId, request) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ ctx.connection.rpc.call('/api', 'goals/create', { args }) → Connection 创建 rpcId 和既有 client-request envelope -→ 当前 carrier 发送 POST /api2/goals/create -→ Connection Host half 执行 trust、反序列化和 RPC 分发 -→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ 当前 carrier 发送 POST /api/goals/create +→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request +→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) → Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId @@ -412,30 +419,30 @@ ctx.api.goals.create(sessionId, request) Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. -The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. This work only extends Connection with general channel registration and invocation capabilities. It does not change existing `/api`, trusted connection, trusted-host, or privileged-method semantics. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries -The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, lookup, Context, and business invocation. Connection only sends `/api2`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. +The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. -`/api` and `/api2` share one Connection, Server, RPC envelope, and connection lifecycle while remaining separate protocols. When Connection migrates from HTTP to WebSocket, `/api2` naturally changes from a physical path to a logical channel. The Remote payload, business decorators, generated DTS, Remote API types, and Agent Scope programming interface remain unchanged. +The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. ## Package boundaries - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api2` handler with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. - `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. -- Connection: owns the single HTTP Server/future WebSocket carrier, RPC envelope, rpcId, serialization, trust, and error transport while carrying the isolated `/api` and `/api2` channels. +- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Initial implementation scope -The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. +The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. -This phase implements Connection's general second-channel API and its current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. +This phase implements Connection's shared-channel interceptor and current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. ## Alternatives considered @@ -455,7 +462,7 @@ This phase implements Connection's general second-channel API and its current HT **Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. -**Create a separate transport, HTTP route, and response envelope for Remote.** This would duplicate the existing Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle, requiring two RPC stacks to migrate separately. `/api2` instead reuses the single Connection/RPC mechanism as an isolated protocol channel. +**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. ## Acceptance criteria @@ -465,10 +472,10 @@ This phase implements Connection's general second-channel API and its current HT - After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. - Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. - `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. -- `/api2/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. -- `/api2` and `/api` share the single Connection/RPC carrier while remaining protocol-isolated. Remote neither registers an HTTP Server handle directly nor defines a second response envelope. -- Connection provides general channel registration and invocation capabilities and maps `/api2` to the current HTTP carrier. Existing `/api` behavior and trust semantics remain unchanged. -- This implementation does not change existing `/api`, Connection/trusted connection, Permission/Approval, or Session event stream behavior. +- `/api/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. +- Gateway mounts into Connection, Connection mounts the single `/api` route into HTTP Server, and Remote defines neither an HTTP route nor a second response envelope. +- Connection's composite FetchHandler dispatches a TypeRT-owned endpoint to Gateway and falls back to API Proxy only when Gateway does not claim it. A withdrawn strict endpoint remains claimed and fails as unavailable. +- Existing API Proxy trust, privileged-method, Permission/Approval, and Session event stream behavior remains unchanged for unclaimed endpoints. ## Risks diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md index 9b2fbbd69f..1e09965d2b 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -20,7 +20,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在唯一 Connection/RPC 机制之上,使用独立 `/api2` channel;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 ## 组件和 Cordis 服务 @@ -30,7 +30,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、RPC envelope、rpcId、序列化、trust 和错误传输,并承载 `/api` 与 `/api2` 两个隔离 channel | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | | Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | | Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | @@ -139,7 +139,7 @@ InvocationDescriptor { LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 -descriptor 只存在于两端本地 registry。wire 上只有 `/api2` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 +descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。 ## TypeRT 运行时 registry @@ -294,20 +294,20 @@ Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api2', endpoint, { args })`。 +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args })`。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api2` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text root ctx.api.goals.create(agentId, request) → direct descriptor - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) agent.goals.create(request) → tracker 将 namespace Service rebind 到 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 - → ctx.connection.rpc.call('/api2', 'goals/create', { args }) + → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 @@ -318,7 +318,7 @@ Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `R Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 -Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api2` RPC 调用。 +Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 @@ -363,22 +363,28 @@ ctx.typertGateway.invoke({ namespace, method, args }) `ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。 -## `/api2` 调用链 +## 共享 `/api` 调用链 -`/api2` 是唯一 Connection/RPC 机制上的独立协议 channel,不是 Gateway 自建的 transport。Gateway 只向 Connection 注册一个本地 handler;本期在现有 HTTP Connection 中增加这项通用 channel 能力: +Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endpoint ownership 判断和 Remote RPC handler 挂到 Connection: ```text -ctx.connection.rpc.handle('/api2', (endpoint, payload) => { - const { namespace, method } = parseEndpoint(endpoint) - const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) -}) +ctx.connection.rpc.intercept( + '/api', + endpoint => ownsRemoteEndpoint(endpoint), + (endpoint, payload) => { + const { namespace, method } = parseEndpoint(endpoint) + const { args } = parsePayload(payload) + return ctx.typertGateway.invoke({ namespace, method, args }) + }, +) ``` -Connection Host half 从唯一 HTTP Server 取得 handle,复用同一 RPC bridge、request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: +Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;只有不属于 Remote 的 endpoint 才进入旧 API Proxy 回退。 + +Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 再选择 Gateway RPC FetchHandler 或 API Proxy FetchHandler;两条路径复用同一 request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: ```text -POST /api2// +POST /api// ``` Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `InvocationDescriptor`。普通 Goal 调用的 payload slot 是: @@ -399,11 +405,12 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` ```text ctx.api.goals.create(sessionId, request) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api2', 'goals/create', { args }) +→ ctx.connection.rpc.call('/api', 'goals/create', { args }) → Connection 创建 rpcId 和既有 client-request envelope -→ 当前 carrier 发送 POST /api2/goals/create -→ Connection Host half 执行 trust、反序列化和 RPC 分发 -→ /api2 handler 调用 ctx.typertGateway.invoke(...) +→ 当前 carrier 发送 POST /api/goals/create +→ Connection Host half 执行共享 trust,再由 bridge 创建标准 Request +→ 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) → Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId @@ -412,30 +419,30 @@ ctx.api.goals.create(sessionId, request) Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 -Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。本工作只扩展 Connection 的通用 channel 注册和调用能力,不改变现有 `/api`、trusted connection、trusted-host 或 privileged method 语义;Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 -API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、lookup、Context 和业务调用。Connection 只负责把 `/api2`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 +API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 -`/api` 与 `/api2` 共享唯一 Connection、Server、RPC envelope 和连接生命周期,但保持协议隔离。Connection 从 HTTP 迁移到 WebSocket 时,`/api2` 从物理路径自然变成逻辑 channel;Remote payload、业务 decorator、生成的 DTS、Remote API 类型和 Agent Scope 编程界面都不变化。 +Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 ## 包边界 - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api2` handler;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 - `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 -- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、RPC envelope、rpcId、序列化、trust 和错误传输,同时承载隔离的 `/api` 与 `/api2` channel。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 首期实现范围 -第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api2 → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 +第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 -本期实现 Connection 的通用第二 channel API 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 +本期实现 Connection 的共享 channel interceptor 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 ## Alternatives considered @@ -455,7 +462,7 @@ API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位 **让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 -**为 Remote 新建独立 transport、HTTP route 和响应信封。** 这会复制现有 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期,并让两个 RPC 栈分别迁移,因此 `/api2` 作为独立协议 channel 复用唯一 Connection/RPC 机制。 +**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 ## Acceptance criteria @@ -465,10 +472,10 @@ API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位 - Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 - Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 - `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 -- `/api2/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 -- `/api2` 与 `/api` 共享唯一 Connection/RPC carrier,但保持协议隔离;Remote 不直接注册 HTTP Server handle,也不定义第二套 response envelope。 -- Connection 提供通用 channel 注册和调用能力,并把 `/api2` 映射到当前 HTTP carrier;现有 `/api` 行为与 trust 语义保持不变。 -- 现有 `/api`、Connection/trusted connection、Permission/Approval 和 Session 事件流行为不因本实现改变。 +- `/api/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 +- Gateway 挂到 Connection,Connection 把唯一 `/api` route 挂到 HTTP Server;Remote 不定义 HTTP route 或第二套 response envelope。 +- Connection 的复合 FetchHandler 将 TypeRT 认领的 endpoint 分发给 Gateway,仅在 Gateway 不认领时回退 API Proxy;已撤回的 strict endpoint 继续被认领并返回 unavailable。 +- 未认领 endpoint 保留既有 API Proxy trust、privileged-method、Permission/Approval 和 Session 事件流行为。 ## Risks diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 05b9bb4141..ddfda12f4e 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e -README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51 +README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b +README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 1393e79aac..161e34c4b6 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 70380ceba1..d17012cc89 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 4e897ccf87..141092c63b 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -16,12 +16,13 @@ import type { IncomingHttpHeaders } from 'node:http' import { isLoopbackHostname } from './loopback-hostname.ts' -/** The request facts the fence reads (structural subset of IncomingMessage). */ +/** The request facts the fence reads from either HTTP representation. */ interface ApiTrustRequest { - headers: IncomingHttpHeaders + headers: IncomingHttpHeaders | Headers } -function header(headers: IncomingHttpHeaders, name: string): string | undefined { +function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined const value = headers[name] return typeof value === 'string' ? value : undefined } @@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool /** * Decide whether one /api request may reach the RPC bridge. - * @param request - node HTTP request facts (headers). + * @param request - Node HTTP or Fetch request facts (headers). * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 88d577bef8..cdf8d12bfe 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,7 +5,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -interface FetchHandler { +/** Transport-independent request handler consumed by the Host HTTP bridge. */ +export interface FetchHandler { + /** + * Handle one standard Fetch request. + * @param request - request produced by the active transport bridge. + * @returns complete or streaming Fetch response. + */ fetch(request: Request): Promise } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index d8b6ef8846..aefdcdadf4 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -12,6 +12,7 @@ import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink export type { ConnectionRpcAuthority, + ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, HostConnectionHandle, @@ -24,7 +25,7 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */ +/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ export const inject = ['httpServer'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -93,35 +94,44 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) - new HostConnectionService(ctx, trustedHosts) + const connection = new HostConnectionService(ctx, trustedHosts) + const fetchHandler = connection.createSharedFetchHandler(API_PATH, { + async fetch(request) { + const pathname = new URL(request.url).pathname + const method = pathname.startsWith(`${API_PATH}/`) + ? pathname.slice(API_PATH.length + 1) + : undefined + if (method !== undefined + && PRIVILEGED_METHODS.has(method) + && !isTrustedApiRequest(request, [])) { + return new Response('forbidden', { status: 403 }) + } + if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { + return new Response('upgrade required', { + status: 426, + headers: { connection: 'Upgrade', upgrade: 'websocket' }, + }) + } + const apiProxy = ctx.get('apiProxy') + if (apiProxy === undefined) return new Response('not found', { status: 404 }) + return toFetchHandler(apiProxy).fetch(request) + }, + }) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + if (!isTrustedApiRequest(req, trustedHosts)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, fetchHandler) + }, + } + ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') ctx.inject(['apiProxy'], (apiCtx) => { - const apiHandler = toFetchHandler(apiCtx.apiProxy) const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) - ? isTrustedApiRequest(req, []) - : isTrustedApiRequest(req, trustedHosts) - if (!allowed) { - res.writeHead(403) - res.end('forbidden') - return - } - if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' }) - res.end('upgrade required') - return - } - await bridge(req, res, apiHandler) - }, - } - apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route') const registerDownlink = ( path: string, handle: WebUpgradeRoute['handler'], diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a6fbdb0264..7d3e5ff6f5 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -11,9 +11,11 @@ import { type RpcId as RpcIdType, type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' -import { bridge } from './http-bridge.ts' +import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' +import { API_PATH } from './api-path.ts' import type { + ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, HostConnectionHandle, @@ -24,8 +26,23 @@ const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ +interface ConnectionRpcInterceptor { + readonly matches: ConnectionRpcEndpointMatcher + readonly fetchHandler: FetchHandler + readonly options: ConnectionRpcHandlerOptions +} + +declare module 'cordis' { + interface Context { + /** Host Connection transport and RPC registrations. */ + connection: HostConnectionHandle + } +} + /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { + private readonly interceptors = new Map() + /** * Provide the Host half over the active HTTP server. * @param ctx - owning Connection plugin context. @@ -40,6 +57,33 @@ export class HostConnectionService extends Service implements HostConnectionHand const owner = this.ctx return { handle: (channel, handler, options) => this.register(owner, channel, handler, options), + intercept: (channel, matches, handler, options) => + this.registerInterceptor(owner, channel, matches, handler, options), + } + } + + /** + * Compose one shared-channel Fetch handler from its interceptor and fallback. + * @param channel - shared channel mounted by Connection. + * @param fallback - handler for endpoints not claimed by the interceptor. + * @returns Fetch handler that selects exactly one target for each request. + */ + createSharedFetchHandler( + channel: '/api', + fallback: FetchHandler, + ): FetchHandler { + return { + fetch: (request) => { + const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + const interceptor = this.interceptors.get(channel) + if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { + return fallback.fetch(request) + } + if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) { + return Promise.resolve(new Response('forbidden', { status: 403 })) + } + return interceptor.fetchHandler.fetch(request) + }, } } @@ -69,12 +113,38 @@ export class HostConnectionService extends Service implements HostConnectionHand `client-connection: ${channel} rpc channel`, ) } + + private registerInterceptor( + owner: Context, + channel: string, + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise { + if (channel !== API_PATH) { + throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`) + } + const interceptor: ConnectionRpcInterceptor = { + matches, + fetchHandler: rpcFetchHandler(channel, handler), + options, + } + return owner.effect(() => { + if (this.interceptors.has(channel)) { + throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`) + } + this.interceptors.set(channel, interceptor) + return () => { + this.interceptors.delete(channel) + } + }, `client-connection: ${channel} rpc interceptor`) + } } function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): { fetch(request: Request): Promise } { +): FetchHandler { return { async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index ab68783724..e1260f00e8 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -18,11 +18,14 @@ export type ConnectionRpcHandler = ( signal: AbortSignal, ) => Promise> +/** Synchronous ownership test for one endpoint on a shared RPC channel. */ +export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean + /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** * Register one absolute channel prefix and its trust policy. - * @param channel - absolute logical channel such as `/api2`. + * @param channel - absolute logical channel such as `/rpc`. * @param handler - decoded endpoint handler returning the existing RPC result shape. * @param options - channel trust policy. * @returns asynchronous disposer removing the channel and its physical route. @@ -32,6 +35,21 @@ export interface HostConnectionRpc { handler: ConnectionRpcHandler, options: ConnectionRpcHandlerOptions, ): () => Promise + + /** + * Intercept owned endpoints on the shared `/api` channel before its fallback. + * @param channel - reserved shared channel; currently `/api`. + * @param matches - synchronous endpoint ownership test. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @param options - trust policy for every endpoint claimed by this interceptor. + * @returns asynchronous disposer removing the interceptor. + */ + intercept( + channel: '/api', + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + options: ConnectionRpcHandlerOptions, + ): () => Promise } /** Host `ctx.connection` shape consumed by transport-independent adapters. */ @@ -44,7 +62,7 @@ export interface HostConnectionHandle { export interface ClientConnectionRpc { /** * Call one endpoint through an already registered logical channel. - * @param channel - absolute logical channel such as `/api2`. + * @param channel - absolute logical channel such as `/api`. * @param endpoint - channel-relative endpoint such as `goals/create`. * @param payload - channel-owned request payload. * @param signal - optional caller cancellation. diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 3ce8b89ecb..6bf9c26b46 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -204,7 +204,7 @@ describe('connection client apply', () => { expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) - it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => { + it('carries RPC calls over the shared API channel with rpcId echo validation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -221,13 +221,13 @@ describe('connection client apply', () => { }) } try { - await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } })) + await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } })) .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) } finally { globalThis.fetch = original } expect(seen).toHaveLength(1) - expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create') + expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create') expect(seen[0]?.body).toMatchObject({ type: 'client-request', method: 'goals/create', @@ -244,10 +244,10 @@ describe('connection client apply', () => { const abort = new AbortController() globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })) try { - await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal)) + await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal)) .rejects.toThrow('HTTP 503') expect(globalThis.fetch).toHaveBeenCalledWith( - new URL('https://harness.example/api2/goals/create'), + new URL('https://harness.example/api/goals/create'), expect.objectContaining({ signal: abort.signal }), ) @@ -257,9 +257,9 @@ describe('connection client apply', () => { rpcId: 'different-rpc', result: { ok: true, value: null }, })) - await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch') + await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch') const fetch = vi.mocked(globalThis.fetch) - expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create')) + expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create')) expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') } finally { globalThis.fetch = original @@ -267,12 +267,12 @@ describe('connection client apply', () => { for (const [channel, endpoint] of [ ['api2', 'goals/create'], - ['/api2/path', 'goals/create'], - ['/api2', ''], - ['/api2', '.'], - ['/api2', '..'], - ['/api2', 'goals//create'], - ['/api2', 'goals/create?unsafe'], + ['/api/path', 'goals/create'], + ['/api', ''], + ['/api', '.'], + ['/api', '..'], + ['/api', 'goals//create'], + ['/api', 'goals/create?unsafe'], ] as const) { await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target') } @@ -281,6 +281,6 @@ describe('connection client apply', () => { it('keeps generic Remote calls unavailable in the client-only fixture', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() - await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 1c42a9dc88..59ab8e6102 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -195,35 +195,36 @@ describe('connection node half', () => { await dispose() }) - it('provides a disposable generic RPC channel without requiring apiProxy', async () => { + it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(routes).toHaveLength(0) + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) const connection = ctx.get('connection') as HostConnectionHandle const calls: unknown[] = [] - const remove = connection.rpc.handle('/api2', async (endpoint, payload) => { + const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => { calls.push({ endpoint, payload }) return { ok: true, value: { accepted: true } } }, { authority: 'trusted-host' }) - const route = routes.find(candidate => candidate.path === '/api2') + const route = routes.find(candidate => candidate.path === '/rpc') expect(route).toBeDefined() const request: ClientRequest = { type: 'client-request', - rpcId: RpcId('rpc-api2'), + rpcId: RpcId('rpc-dedicated'), method: 'goals/create', payload: { args: { agentId: 'agent-1' } }, } const result = fakeResponse() - await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response) + await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response) expect(result.state.status).toBe(200) expect(JSON.parse(String(result.state.body))).toEqual({ type: 'server-response', - rpcId: 'rpc-api2', + rpcId: 'rpc-dedicated', result: { ok: true, value: { accepted: true } }, }) expect(calls).toEqual([{ @@ -231,11 +232,90 @@ describe('connection node half', () => { payload: { args: { agentId: 'agent-1' } }, }]) - expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), { authority: 'trusted-host', })).toThrow(/duplicate route/) await remove() + expect(routes.map(candidate => candidate.path)).toEqual([API_PATH]) + await fiber.dispose() expect(routes).toHaveLength(0) + }) + + it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) + await fiber.await() + const connection = ctx.get('connection') as HostConnectionHandle + const calls: unknown[] = [] + const remove = connection.rpc.intercept( + '/api', + endpoint => endpoint === 'goals/create', + async (endpoint, payload) => { + calls.push({ endpoint, payload }) + return { ok: true, value: { accepted: true } } + }, + { authority: 'trusted-host' }, + ) + expect(() => connection.rpc.intercept( + '/api', + () => true, + async () => ({ ok: true, value: null }), + { authority: 'trusted-host' }, + )).toThrow('already has an interceptor') + expect(() => connection.rpc.intercept( + '/rpc' as '/api', + () => true, + async () => ({ ok: true, value: null }), + { authority: 'trusted-host' }, + )).toThrow('invalid shared RPC channel') + const route = routes.find(candidate => candidate.path === API_PATH)! + const request: ClientRequest = { + type: 'client-request', + rpcId: RpcId('rpc-shared'), + method: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + } + + const claimed = fakeResponse() + await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response) + expect(JSON.parse(String(claimed.state.body))).toEqual({ + type: 'server-response', + rpcId: 'rpc-shared', + result: { ok: true, value: { accepted: true } }, + }) + expect(calls).toEqual([{ + endpoint: 'goals/create', + payload: { args: { agentId: 'agent-1' } }, + }]) + + const denied = fakeResponse() + await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response) + expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + expect(calls).toHaveLength(1) + + const unclaimed = fakeResponse() + await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response) + expect(unclaimed.state.status).toBe(404) + + await remove() + const withdrawn = fakeResponse() + await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response) + expect(withdrawn.state.status).toBe(404) + expect(calls).toHaveLength(1) + + const removeLoopback = connection.rpc.intercept( + '/api', + endpoint => endpoint === 'goals/create', + async () => ({ ok: true, value: null }), + { authority: 'loopback' }, + ) + const loopbackOnly = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response) + expect(loopbackOnly.state.status).toBe(403) + await removeLoopback() await fiber.dispose() }) @@ -246,20 +326,20 @@ describe('connection node half', () => { const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle - const remove = connection.rpc.handle('/api2', async (endpoint) => { + const remove = connection.rpc.handle('/rpc', async (endpoint) => { if (endpoint === 'fail') throw new Error('handler broke') return { ok: true, value: null } }, { authority: 'trusted-host', }) - const route = routes[0]! + const route = routes.find(candidate => candidate.path === '/rpc')! const denied = fakeResponse() - await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) + await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) const methodMismatch = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, }), methodMismatch.response) expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ @@ -268,12 +348,12 @@ describe('connection node half', () => { }) for (const [request, status] of [ - [fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404], + [fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404], [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], - [fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404], - [fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400], + [fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404], + [fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400], ] as const) { const response = fakeResponse() await route.handler(request, response.response) @@ -286,7 +366,7 @@ describe('connection node half', () => { [null, 'invalid-request'], ] as const) { const response = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response) + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response) expect(JSON.parse(String(response.state.body))).toMatchObject({ rpcId, result: { ok: false, error: { code: 'bad-request' } }, @@ -294,7 +374,7 @@ describe('connection node half', () => { } const failed = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', { + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', { type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, }), failed.response) expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 2abe47e0d3..747aa65665 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/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/api-gateway/README.md -README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176 -README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca +README.md: cc80bb19fec15414aa0857154a8a36fb4f642672 +README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 3ef926ace2..cc80bb19fe 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -10,13 +10,13 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. ## Client service: `ClientApi` (ctx key: `api`) `ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 77b8b8a87d..6febb1cfe4 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -10,13 +10,13 @@ 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 ## Client 服务:`ClientApi`(ctx key:`api`) `ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index fe8fd9f1b3..1f92bc0748 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -248,7 +248,7 @@ class ClientApiService extends Service implements ClientApi { }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) - const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal) + const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal) if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) if (!result.ok) throw remoteFailure(endpoint, result.error) return parse(descriptor.result, result.value, endpoint, 'result') diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index c83772261a..2adfaa8387 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -5,6 +5,7 @@ */ import { Context, Service, symbols } from 'cordis' +import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, type InvocationDescriptor, @@ -35,26 +36,7 @@ interface ResolvedBinding { readonly original: object } -type ConnectionRpcResult = - | { readonly ok: true; readonly value: unknown } - | { - readonly ok: false - readonly error: { - readonly code: 'internal' - readonly message: string - readonly details: Record - } - } - -interface HostConnectionLike { - readonly rpc: { - handle( - channel: string, - handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, - options: { readonly authority: 'trusted-host' | 'loopback' }, - ): () => Promise - } -} +type ConnectionRpcResult = Awaited> /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -101,15 +83,32 @@ export class TypertGatewayService extends Service implements TypertGateway { constructor(ctx: Context) { super(ctx, 'typertGateway') ctx.inject(['connection'], (connectionCtx) => { - const connection = connectionCtx.get('connection') as unknown as HostConnectionLike - connection.rpc.handle( - '/api2', + connectionCtx.connection.rpc.intercept( + '/api', + endpoint => this.claimsEndpoint(endpoint), (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), { authority: 'trusted-host' }, ) }) } + private claimsEndpoint(endpoint: string): boolean { + const segments = endpoint.split('/') + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false + const [namespace, method] = segments as [string, string] + if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== 'service') continue + const receiver = this.ctx.get(serviceKey) as unknown + if (!isObject(receiver)) continue + const original = originalOf(receiver) + const binding = Reflect.get(original, 'typertGateway') as unknown + if (!isObject(binding) || Reflect.get(binding, 'namespace') !== namespace) continue + if (remoteMethods(original).some(candidate => (candidate.exportName ?? candidate.method) === method)) return true + } + return false + } + /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 8c0753f3f9..ab08ef09bc 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -109,7 +109,7 @@ describe('Client TypeRT API', () => { await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/create', { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), @@ -144,7 +144,7 @@ describe('Client TypeRT API', () => { await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), @@ -175,7 +175,7 @@ describe('Client TypeRT API', () => { await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( - '/api2', + '/api', 'goals/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 0b550e126d..d5a3f9a8ee 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -96,6 +96,7 @@ type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) class FakeConnectionService extends Service { channel: string | undefined authority: string | undefined + matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined constructor(ctx: Context) { @@ -105,14 +106,21 @@ class FakeConnectionService extends Service { get rpc() { const owner = this.ctx return { - handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) => + intercept: ( + channel: string, + matches: (endpoint: string) => boolean, + handler: FakeRpcHandler, + options: { readonly authority: string }, + ) => owner.effect(() => { this.channel = channel this.authority = options.authority + this.matches = matches this.handler = handler return () => { this.channel = undefined this.authority = undefined + this.matches = undefined this.handler = undefined } }), @@ -820,7 +828,7 @@ describe('TypertGatewayService', () => { }), 'invocation-unavailable') }) - it('mounts /api2 through an optional Connection and returns existing RPC results', async () => { + it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) await ctx.plugin(TypertRegistry) await ctx.plugin(FakeConnectionService) @@ -828,13 +836,18 @@ describe('TypertGatewayService', () => { await gatewayFiber await ctx.plugin(GoalService) const connection = rawConnection(ctx) - expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' }) + expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' }) registerAgentLookup(ctx, { id: 'agent-1' }) registerStrict(ctx, [createDescriptor()]) + expect(connection.matches?.('goals/create')).toBe(true) + expect(connection.matches?.('goals/passthrough')).toBe(true) + expect(connection.matches?.('goals')).toBe(false) + expect(connection.matches?.('goals/missing')).toBe(false) + expect(connection.matches?.('legacy/list')).toBe(false) const signal = new AbortController().signal const handler = connection.handler - if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler') + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') await expect(handler('goals/create', { args: { agentId: 'agent-1', request: { title: 'ship' } }, }, signal)).resolves.toEqual({ @@ -873,7 +886,7 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) - it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => { + it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) @@ -886,11 +899,12 @@ describe('TypertGatewayService', () => { await goalFiber const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' }) const removeStrict = registerStrict(ctx, [createDescriptor()]) + let strictActive = true expect(routes).toHaveLength(1) const server = await serveRoute(routes[0]!) try { - const response = await fetch(`${server.origin}/api2/goals/create`, { + const response = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -909,9 +923,54 @@ describe('TypertGatewayService', () => { value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' }, }, }) + + const invalid = await fetch(`${server.origin}/api/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-invalid', + method: 'goals/create', + payload: { invalid: true }, + }), + }) + expect(invalid.status).toBe(200) + await expect(invalid.json()).resolves.toMatchObject({ + type: 'server-response', + rpcId: 'rpc-invalid', + result: { + ok: false, + error: { code: 'internal', message: expect.stringContaining('plain-object args field') }, + }, + }) + + await removeStrict() + strictActive = false + const withdrawn = await fetch(`${server.origin}/api/goals/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-withdrawn', + method: 'goals/create', + payload: { args: { agentId: 'agent-1', request: { title: 'ship' } } }, + }), + }) + expect(withdrawn.status).toBe(200) + await expect(withdrawn.json()).resolves.toMatchObject({ + type: 'server-response', + rpcId: 'rpc-withdrawn', + result: { + ok: false, + error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') }, + }, + }) + + const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) + expect(unclaimed.status).toBe(404) } finally { await server.close() - await removeStrict() + if (strictActive) await removeStrict() await removeLookup() await goalFiber.dispose() await gatewayFiber.dispose() From cd566f26f56ae8ac4a56c23adec94ee067def193 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:46:02 +0800 Subject: [PATCH 37/88] test(client-remotes): cover shared API bundle chain --- packages/client/remotes/tests/built-lib.e2e.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts index bbba218844..bef3f4ad65 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' /** * Built-artifact smoke for the first generated Remote: plain Node boots the - * Host and Browser bundle handoffs, then crosses the real `/api2` HTTP route. + * Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route. */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) @@ -98,7 +98,9 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { host.agents.register(rootAgent) host.agents.register(scopedAgent) - if (routes.length !== 1) throw new Error('Gateway did not register exactly one /api2 route') + if (routes.length !== 1 || routes[0].path !== '/api') { + throw new Error('Connection did not register exactly one /api route') + } const server = createServer((request, response) => { void routes[0].handler(request, response) }) await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) const address = server.address() From 88385a658e7e1e138e002a9146919abe428633b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:06:54 +0800 Subject: [PATCH 38/88] docs(cordis): refresh gateway service location --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9f5e66ea36..a0fee79546 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2650,7 +2650,7 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/host/api-gateway/src/index.ts:76`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` From 9b63d72c9482c1dfd39f79ec1fd3b0562b521b93 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:49:35 +0800 Subject: [PATCH 39/88] fix(typert): harden remote reflection boundaries --- ...08-02-typert-remote-method-calls.i18n.yaml | 6 + .../2026-08-02-typert-remote-method-calls.md | 66 +++--- ...026-08-02-typert-remote-method-calls.zh.md | 66 +++--- ...08-02-typert-remote-method-calls.i18n.yaml | 6 - docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + docs/core-data-structures/typert.i18n.yaml | 6 + docs/core-data-structures/typert.md | 196 ++++++++++++++++++ docs/core-data-structures/typert.zh.md | 196 ++++++++++++++++++ package.json | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/api-gateway/src/client/index.ts | 1 + packages/host/api-gateway/src/index.ts | 12 +- .../host/api-gateway/tests/client.spec.ts | 15 +- .../host/api-gateway/tests/gateway.spec.ts | 29 +++ packages/typert/generator/src/analyzer.ts | 104 +++++++++- .../generator/tests/remote-model.spec.ts | 26 +++ packages/typert/loader/src/index.ts | 6 +- packages/typert/loader/tests/loader.spec.ts | 52 +++-- packages/typert/registry/src/service.ts | 26 ++- packages/typert/registry/src/types.ts | 7 +- packages/typert/registry/tests/typert.spec.ts | 18 ++ packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 16 ++ scripts/type-equiv.manifest.json | 60 ++++++ 28 files changed, 813 insertions(+), 116 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml rename .agents/notes/{proposed => implemented}/architecture/2026-08-02-typert-remote-method-calls.md (85%) rename .agents/notes/{proposed => implemented}/architecture/2026-08-02-typert-remote-method-calls.zh.md (85%) delete mode 100644 .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml create mode 100644 docs/core-data-structures/typert.i18n.yaml create mode 100644 docs/core-data-structures/typert.md create mode 100644 docs/core-data-structures/typert.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml new file mode 100644 index 0000000000..752a5d4c8b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md +2026-08-02-typert-remote-method-calls.md: 91ab8e44ff8aedf666fe3426b85b54491deb340c +2026-08-02-typert-remote-method-calls.zh.md: 73abd53109d871076aa41af39825c80c35ac3f26 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md similarity index 85% rename from .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md rename to .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 61c8f61468..91ab8e44ff 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -1,6 +1,6 @@ # Agent Note: TypeRT Gateway Targeted Method Calls -Status: proposed +Status: implemented English | [中文](2026-08-02-typert-remote-method-calls.zh.md) @@ -8,13 +8,13 @@ English | [中文](2026-08-02-typert-remote-method-calls.zh.md) The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. -This proposal addresses only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, do not use this design and will be designed separately. +This decision covers only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, remain separate designs. -The contract for a direct method call belongs to the business Service that implements it. Business developers should declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. +The contract for a direct method call belongs to the business Service that implements it. Business developers declare only which methods are remotely callable, without also maintaining a central API interface, routing table, parameter conversion table, client stub, and Zod schema. The Host and Browser Client use separate TypeScript Programs because each side augments the Cordis `Context` type differently. A Remote projection must not import the complete Host declarations into a consumer or depend on Browser-specific types. If the TUI later reuses this programming interface, it must likewise see only methods marked Remote. TUI integration is outside the current scope, but the implementation boundary must preserve this isomorphic reuse. -## Proposal +## Decision A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. @@ -24,7 +24,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T ## Components and Cordis services -| Component | Cordis service | Responsibility in this proposal | +| Component | Cordis service | Responsibility | |---|---|---| | `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | @@ -104,7 +104,7 @@ ctx.typert.lookups.register('agent', { The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on the wire. The runtime provider resolves an `agentId` in a request to the currently live `Agent` object. If either side is missing, the LIB build or the earliest resolvable runtime registration fails immediately. -Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this proposal does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. +Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. @@ -150,7 +150,9 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -Every registration returns a disposer owned by the caller's Cordis fiber. The Gateway and API Service read the current snapshot before subscribing to changes, so business Services, generated contributions, providers, and consumers can load in any order. When any dependency is disposed, its related endpoints or methods become unavailable immediately. +Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway resolves descriptors, Services, and providers from current state for every claim and invocation instead of retaining endpoint registrations. Removing a strict definition, Service, or provider therefore makes the corresponding call unavailable without leaving a stale live object. + +The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -312,11 +314,11 @@ agent.goals.create(request) The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. -Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service can create real functions from that data, so this proposal does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. ## Cross-environment isomorphism constraints -Remote API is a consumer capability, not a synonym for Browser API. This phase implements only Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. +Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. @@ -324,7 +326,7 @@ A future TUI can join the same call abstraction without changing business decora TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. -The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers must rebuild the lib and then start or restart the Web. The first phase does not implement incremental watching of the Remote contract. +The Web already depends on build artifacts such as `lib/client.js`, so it requires a complete `build:lib` before startup. After the Host Remote contract changes, developers rebuild the lib and then start or restart the Web. Incremental watching of the Remote contract is not implemented. ## SRC and LIB operating modes @@ -340,11 +342,11 @@ At runtime, LIB only loads definitions from `lib`; it does not start the TypeScr CI and releases use LIB. Moving all repository coverage to LIB is separate follow-up work and does not block this direct-method-call implementation. -## Host Gateway registration +## Host Gateway resolution -The Host Gateway observes both TypeRT Remote definitions and the Cordis Service lifecycle. When a Service carrying the `typertGateway` facet and a definition with the same service key are both available, the Gateway registers the definition's endpoints. Their arrival order does not matter. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher resolves each endpoint from the current TypeRT local registry or scans current Cordis Services for a matching `typertGateway` binding and SRC Remote marker. TypeRT definitions and business Services may therefore arrive in either order. -At startup, the Gateway reads the current snapshots of TypeRT definitions and the Cordis reflection store before subscribing to registry changes and `internal/service`. It reconciles definitions, live Services, and bindings by service key, and unregisters endpoints when a Service is replaced or disposed. If a definition, lookup provider, or Context provider is removed, dependent endpoints immediately become unavailable; the Gateway neither retains invalid objects nor degrades to invoking methods with raw IDs. +Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. @@ -417,7 +419,7 @@ ctx.api.goals.create(sessionId, request) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The Gateway adapter maps endpoint, schema, lookup, Context, Service, and business-invocation failures to `RpcError`; Connection transports that error. +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. @@ -438,11 +440,11 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. -## Initial implementation scope +## Shipped scope and deferred work -The first vertical path implements `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()` and proves that the same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. The scoped-receiver semantics of `@RemoteContext('agent')` remain a separate mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. -This phase implements Connection's shared-channel interceptor and current HTTP carrier mapping, but not WebSocket migration, the TUI runtime, a TUI carrier, or TUI Agent Scope wiring. This RFC also does not design Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, or cross-version protocol compatibility. +Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision. ## Alternatives considered @@ -464,26 +466,24 @@ This phase implements Connection's shared-channel interceptor and current HTTP c **Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. -## Acceptance criteria +## Verification -- Goal Service retains its existing business method and adds a remote entry point at the end of the class through an explicit `typertGateway` and `@Remote('create') remoteExportCreate(...)`, without maintaining a second route, codec, or Client method list. -- One clean `build:lib` generates the Host Remote contract before compiling Host and Client consumers and produces JS, DTS, and a DTS map under the business package's `lib`, importable through `/remote`. -- After importing `@deepseek-ai/dsh-goal/remote`, a consumer project gets a strict `api.goals.create(...)` type; without the import, that namespace does not enter its types. Go to Definition on `create` follows the declaration map to the Host Service's `remoteExportCreate` implementation. -- After the Client assembly mounts the JS contribution obtained from the same import, TypeRT can reflect endpoint, parameter, result, lookup, Context, and Zod information, and the API Service creates the calling method without a hand-written stub. -- Remote DTS, Remote JS, `RemoteApi`, and the descriptor protocol do not depend on Browser-specific capabilities, and the type model cannot expose unmarked Goal Service methods, preserving the boundary required for future isomorphic TUI integration. -- `agent.goals.*` obtains its call Scope through the Cordis tracker and Context binder. The Root Context has no Agent-only type, and functions are not copied into each Scope. -- `/api/goals/create` resolves `agentId` to the canonical Agent object, invokes the original Goal Service receiver, and returns the result through the existing RPC result/error mechanism. -- Gateway mounts into Connection, Connection mounts the single `/api` route into HTTP Server, and Remote defines neither an HTTP route nor a second response envelope. -- Connection's composite FetchHandler dispatches a TypeRT-owned endpoint to Gateway and falls back to API Proxy only when Gateway does not claim it. A withdrawn strict endpoint remains claimed and fails as unavailable. -- Existing API Proxy trust, privileged-method, Permission/Approval, and Session event stream behavior remains unchanged for unclaimed endpoints. +- Goal Service keeps its existing business method and adds an explicit `typertGateway` plus `@Remote('create') remoteExportCreate(...)`, without a second route, codec, or Client method list. +- A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. +- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. +- Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. +- Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. +- The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. +- Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. +- Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. -## Risks +## Consequences Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts. Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract. -The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib; the first phase has no incremental contract watcher. +The permissive SRC descriptor does not validate the internal structure of ordinary JSON. After a Host Remote signature changes, the Web and strict type consumers must rebuild the lib because no incremental contract watcher exists. Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. @@ -494,3 +494,9 @@ Browser and Host each hold their own Zod instances and cannot compare object ide A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. + +Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. + +`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. + +Connection supplies an `AbortSignal`, but Remote business signatures have no cancellation parameter. A client disconnect therefore does not cancel business work; cancellation remains deferred rather than being implied by the transport handler shape. diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md similarity index 85% rename from .agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md rename to .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 1e09965d2b..73abd53109 100644 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -1,6 +1,6 @@ # Agent Note: TypeRT Gateway 定向方法调用 -Status: proposed +Status: implemented [English](2026-08-02-typert-remote-method-calls.md) | 中文 @@ -8,13 +8,13 @@ Status: proposed Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 -本方案只解决一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流不使用本方案,后续分别设计。 +本决策只涵盖一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流仍采用独立设计。 -直接方法调用的契约属于实现该行为的业务 Service。业务开发者应只声明哪些方法可以远程调用,而不应再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 +直接方法调用的契约属于实现该行为的业务 Service。业务开发者只需声明哪些方法可以远程调用,无需再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。 Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以不同类型合并同名 Cordis `Context`。Remote 投影不能把完整 Host 声明导入消费端,也不能依赖 Browser 专属类型;未来 TUI 若复用这套编程界面,也只能看到 Remote 标记的方法。本期不实现 TUI 接入,但实现边界不得阻断这种同构复用。 -## Proposal +## 决策 业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 @@ -24,7 +24,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 ## 组件和 Cordis 服务 -| 组件 | Cordis 服务 | 本方案中的职责 | +| 组件 | Cordis 服务 | 职责 | |---|---|---| | `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | @@ -104,7 +104,7 @@ ctx.typert.lookups.register('agent', { 静态声明让 TypeRT 知道 `Agent` 在 wire 上对应 `SessionId`;运行时 provider 负责把请求中的 `agentId` 解析为当前活的 `Agent` 对象。缺少任一侧时,LIB 构建或最早可解析的运行时注册直接失败。 -Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本方案不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 +Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本设计不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 @@ -150,7 +150,9 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -每次注册都返回由调用方 Cordis fiber 持有的 disposer。Gateway 和 API Service 先读取当前快照再订阅变化,因此业务 Service、generated contribution、provider 和消费者可以按任意顺序加载;任一依赖 dispose 后,相关 endpoint 或方法立即失效。 +每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 每次认领和调用时都从当前状态解析 descriptor、Service 与提供方,不保留 endpoint 注册。因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 + +lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -312,11 +314,11 @@ agent.goals.create(request) Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 -生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 可以据此创建真实函数,因此本方案不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 ## 跨环境同构约束 -Remote API 是消费端能力,不等同于 Browser API。本期只实现 Browser Client 的 contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 +Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 @@ -324,7 +326,7 @@ Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数 TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 -Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后必须重新执行 lib build,再启动或重启 Web;本方案不在第一阶段实现 Remote contract 的增量 watch。 +Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完整 `build:lib`。Host Remote 契约变化后,开发者需重新执行 lib build,再启动或重启 Web;系统不实现 Remote contract 的增量 watch。 ## SRC 与 LIB 运行模式 @@ -340,11 +342,11 @@ LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工作,不阻塞本次直接方法调用实现。 -## Host Gateway 注册 +## Host Gateway 解析 -Host Gateway 同时观察 TypeRT Remote definition 和 Cordis Service 生命周期。当某个带 `typertGateway` facet 的 Service 与同 service key 的 definition 都可用时,Gateway 注册其 endpoint;两者到达顺序不影响结果。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会从当前 TypeRT local 注册表解析各 endpoint,或扫描当前 Cordis Service,查找匹配的 `typertGateway` binding 与 SRC Remote 标记。因此 TypeRT definition 与业务 Service 可以按任意顺序到达。 -Gateway 启动时先读取 TypeRT definition 和 Cordis reflection store 的当前快照,再订阅 registry change 与 `internal/service`。它按 service key reconcile definition、活 Service 和 binding;Service 被替换或 dispose 时撤销对应 endpoint。definition、lookup provider 或 Context provider 撤销时,依赖它们的 endpoint 立即不可调用,不保留失效对象或降级为原始 ID 调用。 +每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 @@ -417,7 +419,7 @@ ctx.api.goals.create(sessionId, request) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`;Gateway adapter 负责把 endpoint、schema、lookup、Context、Service 和业务调用失败映射为 `RpcError`,Connection 负责传输该错误。 +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 @@ -438,11 +440,11 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 -## 首期实现范围 +## 已交付范围与后续工作 -第一条纵向链路实现 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`,并证明同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 的 scoped receiver 语义继续保留为独立模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 -本期实现 Connection 的共享 channel interceptor 及当前 HTTP carrier 映射,但不实现 WebSocket 迁移、TUI runtime、TUI carrier 或 TUI Agent Scope 接线。本 RFC 也不设计 Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等和跨版本协议兼容。 +Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。 ## Alternatives considered @@ -464,26 +466,24 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H **为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 -## Acceptance criteria +## 验证 -- Goal Service 保留既有业务方法,在类末尾通过显式 `typertGateway` 和 `@Remote('create') remoteExportCreate(...)` 新增远程出口,不维护第二份路由、codec 或 Client 方法清单。 -- 一次干净 `build:lib` 先生成 Host Remote contract,再完成 Host 和 Client 消费端编译,并在业务包 `lib` 下产生可通过 `/remote` 导入的 JS、DTS 和 DTS map。 -- 导入 `@deepseek-ai/dsh-goal/remote` 后,消费 project 获得严格的 `api.goals.create(...)` 类型;不导入时该 namespace 不进入类型;从 `create` 跳转定义会通过 declaration map 到达 Host Service 的 `remoteExportCreate` 实现。 -- Client assembly 挂载同一个 import 得到的 JS contribution 后,TypeRT 能反射 endpoint、参数、结果、lookup、Context 和 Zod 信息,API Service 无需手写 stub 即可创建调用方法。 -- Remote DTS、Remote JS、`RemoteApi` 和 descriptor 协议不依赖 Browser 专属能力,且类型模型无法暴露未标记的 Goal Service 方法,为未来 TUI 同构接入保留边界。 -- `agent.goals.*` 通过 Cordis tracker 和 Context binder 取得调用 Scope,Root Context 不获得 Agent-only 类型,且不为每个 Scope 复制函数。 -- `/api/goals/create` 能把 `agentId` 解析为唯一 Agent 对象,调用原始 Goal Service receiver,并通过既有 RPC result/error 返回结果。 -- Gateway 挂到 Connection,Connection 把唯一 `/api` route 挂到 HTTP Server;Remote 不定义 HTTP route 或第二套 response envelope。 -- Connection 的复合 FetchHandler 将 TypeRT 认领的 endpoint 分发给 Gateway,仅在 Gateway 不认领时回退 API Proxy;已撤回的 strict endpoint 继续被认领并返回 unavailable。 -- 未认领 endpoint 保留既有 API Proxy trust、privileged-method、Permission/Approval 和 Session 事件流行为。 +- Goal Service 保留既有业务方法,并新增显式 `typertGateway` 与 `@Remote('create') remoteExportCreate(...)`,无需第二条路由、第二份 codec 或 Client 方法清单。 +- 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 +- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 +- 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 +- Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 +- Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 +- 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 +- 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 -## Risks +## 后果 Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。 源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。 -SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费者必须重新执行 lib build;第一阶段没有增量 contract watch。 +SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化后,Web 和严格类型消费方必须重新执行 lib build,因为系统没有增量 contract watcher。 公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 @@ -494,3 +494,9 @@ Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 + +Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 + +`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 + +Connection 提供 `AbortSignal`,但 Remote 业务签名没有取消参数。因此 Client 断连不会取消业务工作;取消仍作为后续工作,而不能由 transport handler 的形状暗示已经支持。 diff --git a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml deleted file mode 100644 index 6e7a1a3a13..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-02-typert-remote-method-calls.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/proposed/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 61c8f61468621846fa8e8ff78d52313ae805aa17 -2026-08-02-typert-remote-method-calls.zh.md: 1e09965d2baba2db35301288f338cef15d947f36 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 08f26479ca..41c9c98515 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -308,7 +308,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:32`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0fee79546..99ffaca7c7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2634,7 +2634,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:324`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:346`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index a048f4e43d..461d1ef4fc 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: eb96988abe096455c4f24ac220a6da3f266e690d -core.zh.md: 7334b3d3a5bd088f5467a72d7357f87c4c745487 +core.md: f7cf288715a3aec2f7037f12fc983e3172a77cef +core.zh.md: c17fd1335503c95e7f7f6f96cc286f567a8384e6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index eb96988abe..f7cf288715 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | +| [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, TypeRT registries, and the Host Gateway/Client API seams | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 7334b3d3a5..c17fd13355 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -20,6 +20,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [llm-streaming.md](llm-streaming.md) | `StreamChunk` 协议格式(wire format)+ 适配器契约(adapter contract)、`BlockAssembler`、`LlmAdapter` seam | | [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | +| [typert.md](typert.md) | Remote 调用 descriptor、lookup/Context 声明、TypeRT 注册表,以及 Host Gateway/Client API seam | | [goal.md](goal.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | | [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 | | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml new file mode 100644 index 0000000000..be40eeb20a --- /dev/null +++ b/docs/core-data-structures/typert.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/core-data-structures/typert.md +typert.md: 9f5c63fc554a43fd0248ed08a64dcff566c83b58 +typert.zh.md: 2b74c8325a510ba39d134fa6d463dab273239772 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md new file mode 100644 index 0000000000..9f5c63fc55 --- /dev/null +++ b/docs/core-data-structures/typert.md @@ -0,0 +1,196 @@ +# TypeRT remote calls + +English | [中文](typert.zh.md) + +Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts). + +## Lookup and Context declarations + +Business-object packages extend two empty maps through declaration merging. A lookup associates one Host object type with its wire identity; a Context declaration associates one scoped Context kind with its wire identity. Generated descriptors name these keys, while runtime providers supply the live resolution behavior. + +```ts type-equiv +/** Merge-extensible Host object lookup declarations. */ +interface TypeRTLookupMap {} +``` + +```ts type-equiv +/** Merge-extensible scoped Context declarations. */ +interface TypeRTContextMap {} +``` + +The registry retains a lookup's wire declaration after its resolver unloads. SRC discovery therefore continues to classify the parameter as a lookup and fails unavailable instead of accepting the wire value as an ordinary business object. + +```ts type-equiv +/** Stable wire declaration retained after a lookup provider unloads. */ +interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} +``` + +## Invocation descriptors + +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. + +```ts type-equiv +/** Codec attached to one invocation parameter or result. */ +type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } +``` + +```ts type-equiv +/** One ordered business parameter in a Remote invocation. */ +interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} +``` + +```ts type-equiv +/** Carrier-independent description of one exported method invocation. */ +interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT registry + +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, live lookup providers, and scoped Context providers. Registrations are Cordis-owned effects and return awaitable disposers. + +```ts type-equiv +/** Minimal TypeRT runtime consumed through dependency inversion. */ +interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} +``` + +Generated consumer declarations merge direct namespaces into the map inherited by `ClientApi`. + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client API services. */ +interface TypeRTRemoteNamespaceMap {} +``` + +## Host Gateway + +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. + +```ts type-equiv +/** One Remote method request after a carrier has decoded its envelope. */ +interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} +``` + +```ts type-equiv +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' +``` + +```ts type-equiv +/** Host dispatcher consumed by Connection adapters. */ +interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## Consumer API + +`ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. + +```ts type-equiv +/** Typed API service augmented by generated direct Remote namespaces. */ +interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} +``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md new file mode 100644 index 0000000000..2b74c8325a --- /dev/null +++ b/docs/core-data-structures/typert.zh.md @@ -0,0 +1,196 @@ +# TypeRT 远程调用 + +[English](typert.md) | 中文 + +以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts) 中公共契约的字面定义。 + +## Lookup 与 Context 声明 + +业务对象包通过声明合并扩展两个空 map。lookup 将一种 Host 对象类型与其 wire identity 关联;Context 声明将一种 scoped Context 类别与其 wire identity 关联。生成的 descriptor 引用这些 key,运行时提供方则提供活对象解析行为。 + +```ts type-equiv +/** Merge-extensible Host object lookup declarations. */ +interface TypeRTLookupMap {} +``` + +```ts type-equiv +/** Merge-extensible scoped Context declarations. */ +interface TypeRTContextMap {} +``` + +lookup 的 resolver 卸载后,注册表仍会保留其 wire 声明。因此 SRC 发现过程会继续把该参数归类为 lookup,并因不可用而失败,而不会把 wire 值当作普通业务对象接受。 + +```ts type-equiv +/** Stable wire declaration retained after a lookup provider unloads. */ +interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} +``` + +## 调用 descriptor + +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。 + +```ts type-equiv +/** Codec attached to one invocation parameter or result. */ +type TypeRTCodec = + | { + readonly mode: 'strict' + readonly typeSymbol: string + readonly schema: TypeRTSchema + } + | { + readonly mode: 'src-json' + } +``` + +```ts type-equiv +/** One ordered business parameter in a Remote invocation. */ +interface InvocationParameterDescriptor { + /** Source-level parameter name. */ + readonly name: string + /** Required key in the wire `args` object. */ + readonly wire: string + /** Whether the value is JSON or requires a registered Host lookup. */ + readonly source: 'json' | 'lookup' + /** Lookup key when `source` is `lookup`. */ + readonly lookup?: string + /** Boundary codec for the wire representation. */ + readonly codec: TypeRTCodec +} +``` + +```ts type-equiv +/** Carrier-independent description of one exported method invocation. */ +interface InvocationDescriptor { + /** Globally stable generated identity. */ + readonly id: string + /** Cordis service key owning the method. */ + readonly service: string + /** Wire namespace, defaulting to the service key. */ + readonly namespace: string + /** Public instance method name. */ + readonly method: string + /** Service member invoked when the exported method name is an alias. */ + readonly implementation?: string + /** Receiver selection mode. */ + readonly invocation: + | { readonly kind: 'direct' } + | { + readonly kind: 'context' + readonly context: string + readonly wire: string + readonly codec: TypeRTCodec + } + /** Optional consuming-Context projection for one direct lookup parameter. */ + readonly scope?: { + /** Context kind whose Client binder supplies the identity. */ + readonly context: string + /** Lookup parameter wire field replaced by the Context identity. */ + readonly wire: string + } + /** Ordered business parameters. */ + readonly parameters: readonly InvocationParameterDescriptor[] + /** Codec for the resolved method result. */ + readonly result: TypeRTCodec + /** Source declaration used only for diagnostics. */ + readonly sourceLocation?: InvocationSourceLocation +} +``` + +## TypeRT 注册表 + +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、活 lookup 提供方与 scoped Context 提供方。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 + +```ts type-equiv +/** Minimal TypeRT runtime consumed through dependency inversion. */ +interface TypeRTService { + readonly local: TypeRTLocalRegistry + readonly remotes: TypeRTRemoteRegistry + readonly lookups: TypeRTLookupRegistry + readonly contexts: TypeRTContextRegistry +} +``` + +生成的消费方声明会把 direct namespace 合并到 `ClientApi` 继承的 map 中。 + +```ts type-equiv +/** Merge-extensible direct namespace surface generated for Client API services. */ +interface TypeRTRemoteNamespaceMap {} +``` + +## Host Gateway + +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求携带精确的具名 wire 字段;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 + +```ts type-equiv +/** One Remote method request after a carrier has decoded its envelope. */ +interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string + /** Exported Service method name. */ + readonly method: string + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly> +} +``` + +```ts type-equiv +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +type TypertGatewayErrorCode = + | 'ambiguous-endpoint' + | 'arguments-invalid' + | 'binding-invalid' + | 'context-failed' + | 'context-not-found' + | 'context-unavailable' + | 'definition-unavailable' + | 'input-invalid' + | 'invocation-unavailable' + | 'lookup-failed' + | 'lookup-not-found' + | 'lookup-unavailable' + | 'method-unavailable' + | 'provider-mismatch' + | 'result-invalid' + | 'service-unavailable' + | 'signature-invalid' +``` + +```ts type-equiv +/** Host dispatcher consumed by Connection adapters. */ +interface TypertGateway { + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the validated business result. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + */ + invoke(request: InvokeRemoteRequest): Promise +} +``` + +## 消费方 API + +`ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 + +```ts type-equiv +/** Typed API service augmented by generated direct Remote namespaces. */ +interface ClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} +``` diff --git a/package.json b/package.json index 9d0cac6d5e..a327e36ec3 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "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", + "typecheck": "npm run build:lib:contracts && 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", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 48f4aadeed..4fe2b12323 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -3097,7 +3097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertContribution', - declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}', + declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations: readonly InvocationDescriptor[];\n}', }, { name: 'TypeRTDisposer', diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 1f92bc0748..5cd8ab75d1 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -284,6 +284,7 @@ class ScopedRemoteNamespace extends Service { install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.assertMethodAvailable(descriptor.method) + if (this.methods.size === 0) this.ownerCtx.set(this.name, this) const method = descriptor.method Object.defineProperty(this, method, { configurable: true, diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 2adfaa8387..64d5715719 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -12,7 +12,6 @@ import { type InvocationParameterDescriptor, type TypeRTCodec, type TypeRTGatewayBinding, - type TypeRTLookupProvider, } from '@deepseek-ai/dsh-type-meta' import type { InvokeRemoteRequest, @@ -149,6 +148,7 @@ export class TypertGatewayService extends Service implements TypertGateway { payload: unknown, _signal: AbortSignal, ): Promise { + // Remote methods have no cancellation parameter yet, so disconnects do not cancel business work. return this.invokeRpc(endpoint, payload) } @@ -229,10 +229,8 @@ export class TypertGatewayService extends Service implements TypertGateway { const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() for (const name of names) { - const matches = this.ctx.typert.lookups.keys() - .map(key => ({ key, provider: this.ctx.typert.lookups.get(key) })) - .filter((entry): entry is { key: string; provider: TypeRTLookupProvider } => - entry.provider?.parameter === name) + const matches = this.ctx.typert.lookups.definitions() + .filter(definition => definition.parameter === name) if (matches.length > 1) { throw new TypertGatewayError( 'signature-invalid', @@ -246,7 +244,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } } : { name, - wire: match.provider.wire, + wire: match.wire, source: 'lookup', lookup: match.key, codec: { mode: 'src-json' }, @@ -540,7 +538,7 @@ function decode( field: string, ): unknown { try { - if (codec.mode === 'strict') return codec.schema.parse(value) + if (codec.mode === 'strict') value = codec.schema.parse(value) assertJsonValue(value, new Set()) return value } catch (cause) { diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index ab08ef09bc..2e00d29c0d 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -204,7 +204,13 @@ describe('Client TypeRT API', () => { }) it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { - const ctx = await bench(vi.fn()) + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext + ctx.typert.contexts.registerClient('fixture', { + identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + }) const direct = directDescriptor() const context = contextDescriptor() @@ -242,6 +248,13 @@ describe('Client TypeRT API', () => { package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) + await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + expect(call).toHaveBeenLastCalledWith( + '/api', + 'goals/rename', + { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } }, + expect.any(AbortSignal), + ) await disposeMultipleScoped() }) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index d5a3f9a8ee..4aeadeedb8 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -370,6 +370,19 @@ describe('TypertGatewayService', () => { })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) }) + it('does not downgrade an observed SRC lookup after its provider unloads', async () => { + const { ctx, service } = await setup() + const dispose = registerAgentLookup(ctx, { id: 'agent-1' }) + await dispose() + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-unavailable') + expect(service.calls).toEqual([]) + }) + it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { const { ctx } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-src' }) @@ -657,6 +670,22 @@ describe('TypertGatewayService', () => { }), 'result-invalid') }) + it('rejects non-JSON values after strict codec validation', async () => { + const { ctx, service } = await setup() + const descriptor = strictOnlyDescriptor() + registerStrict(ctx, [{ + ...descriptor, + result: strictCodec('@fixture/gateway#UnknownResult', z.unknown()), + }]) + service.nextResult = 1n + + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'strictOnly', + args: { request: { title: 'ship' } }, + }), 'result-invalid') + }) + it.each([ undefined, Number.NaN, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 5757d7cef5..f430d757fb 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1318,6 +1318,8 @@ class FaceAnalyzer { * type evaluator. */ private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId { + const resolvedType = this.checker.getTypeFromTypeNode(authoredType) + this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false) const completed = new Map() const active = new Map() const recursiveDeclarations = new Map() @@ -1474,7 +1476,107 @@ class FaceAnalyzer { active.delete(type) } } - return convert(this.checker.getTypeFromTypeNode(authoredType)) + return convert(resolvedType) + } + + private assertRemoteJsonType( + type: ts.Type, + site: ts.TypeNode, + active: Set, + allowUndefined: boolean, + ): void { + const flags = type.flags + if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return + if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) { + this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`) + } + if ((flags & (ts.TypeFlags.BigIntLike | ts.TypeFlags.ESSymbolLike | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) { + this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) + } + if ((flags & (ts.TypeFlags.StringLike + | ts.TypeFlags.NumberLike + | ts.TypeFlags.BooleanLike + | ts.TypeFlags.Null + | ts.TypeFlags.Never)) !== 0) return + if (type.isUnion()) { + for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined) + return + } + if (type.isIntersection()) { + const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) + if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') + for (const member of material) this.assertRemoteJsonType(member, site, active, false) + return + } + if ((flags & ts.TypeFlags.TypeParameter) !== 0) { + this.fail(site, 'Remote boundary contains an unresolved type parameter') + } + if ((flags & ts.TypeFlags.Object) === 0) { + this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) + } + const symbol = type.getSymbol() + const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0] + if (declaration !== undefined && (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) { + this.fail(site, `Remote boundary contains class instance ${symbol?.name ?? this.checker.typeToString(type)}`) + } + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) { + this.fail(site, 'Remote boundary contains callable or constructable data') + } + if (active.has(type)) return + active.add(type) + try { + if (this.checker.isTupleType(type)) { + const reference = type as ts.TypeReference + const target = reference.target as ts.TupleType + const arguments_ = this.checker.getTypeArguments(reference) + arguments_.forEach((argument, index) => { + const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required + this.assertRemoteJsonType( + argument, + site, + active, + (elementFlags & ts.ElementFlags.Optional) !== 0, + ) + }) + return + } + if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { + const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) + if (element === undefined) this.fail(site, 'Remote boundary array has no element type') + this.assertRemoteJsonType(element, site, active, false) + return + } + const properties = this.checker.getPropertiesOfType(type) + if (properties.some(property => property.getName().startsWith('__@'))) { + this.fail(site, 'Remote boundary contains a symbol-keyed property') + } + for (const property of properties) { + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] + const propertyType = this.checker.getTypeOfSymbolAtLocation(property, propertyDeclaration ?? site) + this.assertRemoteJsonType( + propertyType, + site, + active, + (property.flags & ts.SymbolFlags.Optional) !== 0, + ) + } + for (const info of this.checker.getIndexInfosOfType(type)) { + if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { + this.fail(site, 'Remote boundary contains a symbol index signature') + } + this.assertRemoteJsonType(info.type, site, active, false) + } + } finally { + active.delete(type) + } + } + + private isRemotePhantomConstraint(type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true + if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false + if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) return false + if (this.checker.getIndexInfosOfType(type).length > 0) return false + return this.checker.getPropertiesOfType(type).every(property => property.getName().startsWith('__@')) } private resolvedCycleReference( diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 90056e673e..cb6e6e6060 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -284,6 +284,32 @@ export type GenericResult = { expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/) }) + it.each([ + ['bigint', 'bigint'], + ['symbol', 'symbol'], + ['undefined', 'undefined'], + ['any', 'unconstrained any'], + ['unknown', 'unconstrained unknown'], + ])('rejects non-JSON Remote boundary type %s', (type, message) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace( + ' readonly title: string\n}', + ` readonly title: string\n readonly invalid: ${type}\n}`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + + it('keeps optional JSON object fields valid', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => source.replace( + ' readonly title: string\n}', + ' readonly title: string\n readonly note?: string\n}', + )) + + expect(() => analyzeRemote(root)).not.toThrow() + }) + it('rejects a Remote Context without a static Context declaration', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index fee1098340..efe0fa6f94 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -135,10 +135,8 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type requireMembers(pkgName, object.members, `object "${object.name as string}"`) requireTypes(pkgName, object.types, `object "${object.name as string}"`) } - if (manifest.invocations !== undefined) { - for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { - requireInvocation(pkgName, value) - } + for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) { + requireInvocation(pkgName, value) } return manifest as unknown as TypertContribution } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 1e7e553605..ec407f82d9 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -60,6 +60,7 @@ function typertSource(pkgName: string, entryName: string): string { ' face: \'host\',', ` schemas: [{ name: '${entryName}', schema: ${entryName} }],`, ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', '}', '', ].join('\n') @@ -262,6 +263,7 @@ describe('typert loader', () => { ' face: \'host\',', ' schemas: [{ name: \'Pending\', schema: Pending }],', ' model: { services: [], events: [], objects: [] },', + ' invocations: [],', '}', '', ].join('\n'), @@ -295,7 +297,7 @@ describe('typert loader', () => { root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-')) await linkZod(root) await writePackage(root, '@fixture/broken', { - typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] } }\n', + typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] }, invocations: [] }\n', }) const ctx = await boot() await ctx.loader.create({ name: '@fixture/broken' }) @@ -410,6 +412,7 @@ describe('validateTypertManifest', () => { face: 'host', schemas: [{ name: 'A', schema: zodish }], model: { services: [], events: [], objects: [] }, + invocations: [], }).schemas).toHaveLength(1) expect(() => validateTypertManifest('pkg', undefined)).toThrow('no TYPERT manifest object') @@ -490,12 +493,14 @@ describe('validateTypertManifest', () => { })).toThrow('object has a missing or empty exportName') }) - it('validates strict invocation descriptors and accepts legacy manifests without them', () => { - const legacy = completeManifest(zodish) - expect(validateTypertManifest('pkg', legacy)).toBe(legacy) + it('requires and validates strict invocation descriptors', () => { + const base = completeManifest(zodish) + const { invocations: _invocations, ...missingInvocations } = base + expect(() => validateTypertManifest('pkg', missingInvocations)) + .toThrow('TYPERT.invocations must be an array') const descriptor = strictInvocation() - const manifest = { ...legacy, invocations: [descriptor] } + const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) const scoped = { ...descriptor, @@ -508,53 +513,53 @@ describe('validateTypertManifest', () => { codec: strictCodec('pkg#AgentId'), }, ...descriptor.parameters], } - expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations) + expect(validateTypertManifest('pkg', { ...base, invocations: [scoped] }).invocations) .toEqual([scoped]) - expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} })) + expect(() => validateTypertManifest('pkg', { ...base, invocations: {} })) .toThrow('TYPERT.invocations must be an array') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, invocation: { kind: 'future' } }], })).toThrow('receiver kind must be "direct" or "context"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, result: { mode: 'src-json' } }], })).toThrow('result codec must use a strict codec') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], })).toThrow('result codec is not backed by a zod v4 schema') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], source: 'future' }], }], })).toThrow('parameter source must be "json" or "lookup"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], source: 'lookup' }], }], })).toThrow('lookup parameter has a missing or empty lookup') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }], }], })).toThrow('JSON parameter declares a lookup') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }], }], })).toThrow('repeats wire field "request"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, invocation: { @@ -566,19 +571,19 @@ describe('validateTypertManifest', () => { }], })).toThrow('repeats Context wire field "request"') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: null }], })).toThrow('scope must be an object') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { wire: 'agentId' } }], })).toThrow('scope has a missing or empty context') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'agent' } }], })).toThrow('scope has a missing or empty wire') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, invocation: { @@ -590,11 +595,11 @@ describe('validateTypertManifest', () => { }], })).toThrow('Context receiver cannot declare a direct scope projection') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, parameters: [...scoped.parameters, { @@ -607,11 +612,11 @@ describe('validateTypertManifest', () => { }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }], })).toThrow('must select its only lookup parameter') expect(() => validateTypertManifest('pkg', { - ...legacy, + ...base, invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }], })).toThrow('sourceLocation.line must be a positive integer') }) @@ -646,6 +651,7 @@ function completeManifest(zodish: object) { package: 'pkg', face: 'host', schemas: [{ name: 'Schema', schema: zodish }], + invocations: [], model: { services: [{ key: 'service', diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 4973732fad..6749cdbeb9 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -17,6 +17,7 @@ import type { TypeRTHostContextProvider, TypeRTLocalRegistry, TypeRTLookupHost, + TypeRTLookupDefinition, TypeRTLookupMap, TypeRTLookupProvider, TypeRTLookupRegistry, @@ -212,6 +213,7 @@ class RemoteStore { class LookupStore { private readonly providers = new Map>() + private readonly definitions = new Map() private readonly changes: ChangeSource constructor(report: ReportObserverError) { @@ -228,6 +230,7 @@ class LookupStore { >, ) => this.register(ctx, key, provider), get: key => this.providers.get(key)?.provider, + definitions: () => [...this.definitions.values()], keys: () => [...this.providers.keys()], subscribe: listener => this.changes.subscribe(ctx, listener), } @@ -240,10 +243,22 @@ class LookupStore { validateNonempty('lookup Host type symbol', provider.hostTypeSymbol) validateNonempty('lookup wire type symbol', provider.wireTypeSymbol) if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`) + const definition: TypeRTLookupDefinition = { + key, + parameter: provider.parameter, + wire: provider.wire, + hostTypeSymbol: provider.hostTypeSymbol, + wireTypeSymbol: provider.wireTypeSymbol, + } + const known = this.definitions.get(key) + if (known !== undefined && !lookupDefinitionEquals(known, definition)) { + throw new Error(`typert: lookup "${key}" changed its wire declaration during this registry lifetime`) + } const owner = {} const entry: ProviderEntry = { provider, owner } - const { providers, changes } = this + const { definitions, providers, changes } = this return ctx.effect(function* () { + definitions.set(key, definition) providers.set(key, entry) changes.emit({ kind: 'lookup', key }) yield () => { @@ -256,6 +271,13 @@ class LookupStore { } } +function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean { + return left.parameter === right.parameter + && left.wire === right.wire + && left.hostTypeSymbol === right.hostTypeSymbol + && left.wireTypeSymbol === right.wireTypeSymbol +} + class ContextStore { private readonly hosts = new Map>() private readonly clients = new Map>() @@ -377,7 +399,7 @@ export class TypertRegistry extends Service implements TypeRTService { register(contribution: TypertContribution): TypeRTDisposer { const packageRecord = this.validatePackage(contribution) const schemaRecords = this.validateSchemas(contribution) - const invocations = contribution.invocations ?? [] + const invocations = contribution.invocations this.localStore.validate(invocations) const owner = {} const { schemas, packages, localStore } = this diff --git a/packages/typert/registry/src/types.ts b/packages/typert/registry/src/types.ts index 6ba0e0f1f2..4dcfc4b7a1 100644 --- a/packages/typert/registry/src/types.ts +++ b/packages/typert/registry/src/types.ts @@ -83,12 +83,7 @@ export interface TypertContribution { readonly face: TypertFace readonly schemas: readonly TypertSchema[] readonly model: TypertPackageModel - /** Host invocation definitions; absent on artifacts generated before Remote support. */ - readonly invocations?: readonly InvocationDescriptor[] -} - -/** Generated Host contribution with strict Remote invocation definitions. */ -export interface TypertLocalContribution extends TypertContribution { + /** Host invocation definitions, empty when the package exports no Remote methods. */ readonly invocations: readonly InvocationDescriptor[] } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 95f8bc871f..51e7594749 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -36,6 +36,7 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })): package: '@deepseek-ai/dsh-tools', face: 'host', schemas: [{ name: 'ToolInput', schema }], + invocations: [], model: { services: [{ key: 'tools', @@ -329,11 +330,19 @@ describe('TypertRegistry', () => { }) expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) + expect(ctx.typert.lookups.definitions()).toEqual([{ + key: 'fixture', + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + }]) expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) expect(ctx.typert.lookups.keys()).toEqual([]) + expect(ctx.typert.lookups.definitions()).toHaveLength(1) expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) @@ -378,6 +387,15 @@ describe('TypertRegistry', () => { ]) await Promise.all([disposeLookupSubscription(), disposeContextSubscription()]) + for (const changed of [ + { ...lookup, parameter: 'session' }, + { ...lookup, wire: 'sessionId' }, + { ...lookup, hostTypeSymbol: '@fixture#Session' }, + { ...lookup, wireTypeSymbol: '@fixture#SessionId' }, + ]) { + expect(() => ctx.typert.lookups.register('fixture', changed)) + .toThrow('changed its wire declaration during this registry lifetime') + } ctx.typert.lookups.register('fixture', lookup) expect(changes).toHaveLength(6) }) diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1e79bb2e55..92438ee0fa 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -20,6 +20,7 @@ export type { TypeRTHostContextProvider, TypeRTLocalRegistry, TypeRTLookup, + TypeRTLookupDefinition, TypeRTLookupHost, TypeRTLookupMap, TypeRTLookupProvider, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 87ab091075..f9ed7ffa97 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -189,6 +189,20 @@ export interface TypeRTLookupProvider { resolve(id: Wire): Host | undefined } +/** Stable wire declaration retained after a lookup provider unloads. */ +export interface TypeRTLookupDefinition { + /** Merge-declared lookup key. */ + readonly key: string + /** Source parameter name recognized by the SRC weak parser. */ + readonly parameter: string + /** Wire field replacing the Host object parameter. */ + readonly wire: string + /** Canonical Host type symbol used by strict generation. */ + readonly hostTypeSymbol: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} + /** Host resolver for one scoped Remote Context kind. */ export interface TypeRTHostContextProvider { /** Wire field carrying the Context identity. */ @@ -291,6 +305,8 @@ export interface TypeRTLookupRegistry { * @returns the live provider, or `undefined` when absent. */ get(key: string): TypeRTLookupProvider | undefined + /** @returns lookup declarations observed during this TypeRT Service lifetime. */ + definitions(): readonly TypeRTLookupDefinition[] /** @returns a snapshot of registered provider keys. */ keys(): readonly string[] /** diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 84b957e633..edadc3f134 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1494,6 +1494,66 @@ "doc": "docs/core-data-structures/settings.md", "symbol": "SettingsPathOp", "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTLookupMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTContextMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTLookupDefinition", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTCodec", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvocationParameterDescriptor", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvocationDescriptor", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTService", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypeRTRemoteNamespaceMap", + "source": "packages/typert/type-meta/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "InvokeRemoteRequest", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGatewayErrorCode", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "TypertGateway", + "source": "packages/host/api-gateway/src/types.ts" + }, + { + "doc": "docs/core-data-structures/typert.md", + "symbol": "ClientApi", + "source": "packages/host/api-gateway/src/client/index.ts" } ] } From 22bec5e63f1656a7c0c3a931a8293f1fb4a223a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:13:15 +0800 Subject: [PATCH 40/88] feat(typert): propagate Remote cancellation --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 37 ++++++++++------ ...026-08-02-typert-remote-method-calls.zh.md | 37 ++++++++++------ docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 11 ++++- docs/core-data-structures/typert.zh.md | 11 ++++- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 4 +- packages/host/api-gateway/README.zh.md | 4 +- packages/host/api-gateway/src/client/index.ts | 14 ++++-- packages/host/api-gateway/src/index.ts | 26 ++++++++--- packages/host/api-gateway/src/types.ts | 2 + .../host/api-gateway/tests/client.spec.ts | 37 ++++++++++++++-- .../host/api-gateway/tests/gateway.spec.ts | 43 +++++++++++++++++-- packages/typert/generator/src/analyzer.ts | 23 +++++++++- packages/typert/generator/src/emitter.ts | 4 ++ packages/typert/generator/src/model.ts | 3 ++ .../remote-model/packages/remote/src/index.ts | 3 +- .../generator/tests/remote-model.spec.ts | 31 +++++++++++-- packages/typert/loader/src/index.ts | 6 +++ packages/typert/loader/tests/loader.spec.ts | 13 ++++++ packages/typert/registry/src/service.ts | 3 ++ packages/typert/registry/tests/typert.spec.ts | 5 +++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 + packages/typert/type-meta/README.zh.md | 2 + packages/typert/type-meta/src/types.ts | 5 +++ 28 files changed, 280 insertions(+), 66 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 752a5d4c8b..bd83c38a3e 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 91ab8e44ff8aedf666fe3426b85b54491deb340c -2026-08-02-typert-remote-method-calls.zh.md: 73abd53109d871076aa41af39825c80c35ac3f26 +2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af +2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 91ab8e44ff..4268539ecf 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -76,6 +76,8 @@ An endpoint selects exactly one invocation mode. A flow that needs an explicit ` Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. + ## Decorators and the explicit Gateway facet A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. -Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. +Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. A cancellation descriptor reserves only the final `signal` position and keeps it outside named `args`; Connection or a direct Gateway caller supplies the actual signal. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults. A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same TypeRT model and symbol keys. @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client` `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args })`. +The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. @@ -332,11 +338,11 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. -For example, `@Remote('create') remoteExportCreate(agent, request)` resolves to the external method `create`, implementation member `remoteExportCreate`, and two top-level parameters. Lookup registration rewrites `agent` to the wire field `agentId`, while `request` is passed as a same-named JSON parameter. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. +For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. -LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, and result codecs, then generates strict descriptors. +LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors. At runtime, LIB only loads definitions from `lib`; it does not start the TypeScript compiler. The subsequent association of Services, lookup, Context resolution, invocation, and response encoding in the Host Gateway does not depend on whether a descriptor came from permissive SRC parsing or strict LIB generation. @@ -348,17 +354,18 @@ The Host Gateway registers one `/api` interceptor with Connection and does not m Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. -An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order. +An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation. A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection owns one `/api` route on the HTTP Server. The Gateway mounts a synchr ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ The Remote payload is a named JSON object, not a positional array, and does not The complete path is: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. -The Gateway does not handle per-method permissions, caller identity, cancellation, idempotency, or long-lived connection state. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries @@ -475,6 +483,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. +- Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. - Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. ## Consequences @@ -499,4 +508,4 @@ Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted `hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. -Connection supplies an `AbortSignal`, but Remote business signatures have no cancellation parameter. A client disconnect therefore does not cancel business work; cancellation remains deferred rather than being implied by the transport handler shape. +Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 73abd53109..f9f426f2fb 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -76,6 +76,8 @@ export class ScopedGoalService extends Service { 业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 + ## Decorator 与显式 Gateway facet Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 @@ -126,6 +128,7 @@ InvocationDescriptor { parameters: [ { name, wire, source: json | lookup, lookup?, codec } ] + cancellation?: { parameter: 'signal' } result: codec sourceLocation } @@ -135,7 +138,7 @@ InvocationDescriptor { 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 -参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 +参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。 LIB codec 带有 Zod schema 和“package + 公共 subpath + export name”的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 TypeRT 模型和 symbol key 生成。 @@ -239,6 +242,7 @@ interface TypeRTRemoteNamespace$676f616c73 { create: ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -246,6 +250,7 @@ interface TypeRTRemoteMap { 'goals/create': ( agentId: SessionId, request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } @@ -256,6 +261,7 @@ interface TypeRTRemoteNamespaceMap { interface TypeRTRemoteContextMap { 'agent:goals/create': ( request: CreateGoalRequest, + signal?: AbortSignal, ) => Promise } ``` @@ -296,7 +302,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args })`。 +API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 @@ -332,11 +338,11 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 -例如 `@Remote('create') remoteExportCreate(agent, request)` 解析为外部方法 `create`、实现成员 `remoteExportCreate` 和两个顶层参数;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 +例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 -LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec 和结果 codec,并生成严格 descriptor。 +LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。 LIB 运行时只加载 `lib` 中的 definition,不启动 TypeScript compiler。Host Gateway 后续的 Service 关联、lookup、Context 解析、调用和响应编码不区分 descriptor 来自 SRC 弱解析还是 LIB 严格生成。 @@ -348,17 +354,18 @@ Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 -普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员。 +普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 `@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 ```text -ctx.typertGateway.invoke({ namespace, method, args }) +ctx.typertGateway.invoke({ namespace, method, args, signal }) → 查找本地 InvocationDescriptor 与 live receiver → 按参数 descriptor 读取具名 wire 字段 → codec 解码普通值或 lookup ID → lookup provider 把 ID 解析为活对象 → direct 使用原 Service;context 先解析 scoped Context 和 Service +→ cancellation descriptor 存在时把 signal 追加到业务参数末尾 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs) → result codec 编码业务结果 ``` @@ -373,10 +380,10 @@ Connection 在 HTTP Server 上持有唯一 `/api` route。Gateway 把同步 endp ctx.connection.rpc.intercept( '/api', endpoint => ownsRemoteEndpoint(endpoint), - (endpoint, payload) => { + (endpoint, payload, signal) => { const { namespace, method } = parseEndpoint(endpoint) const { args } = parsePayload(payload) - return ctx.typertGateway.invoke({ namespace, method, args }) + return ctx.typertGateway.invoke({ namespace, method, args, signal }) }, ) ``` @@ -405,15 +412,16 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request) +ctx.api.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } -→ ctx.connection.rpc.call('/api', 'goals/create', { args }) +→ Client 合并 caller signal 与 contribution mount lifetime +→ ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) → Connection 创建 rpcId 和既有 client-request envelope → 当前 carrier 发送 POST /api/goals/create → Connection Host half 执行共享 trust,再由 bridge 创建标准 Request → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler -→ TypeRT interceptor 调用 ctx.typertGateway.invoke(...) -→ Host InvocationDescriptor 解码、lookup、receiver 解析和 Reflect.apply +→ TypeRT interceptor 调用 ctx.typertGateway.invoke(..., request.signal) +→ Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply → result codec 编码 → Connection 写入既有 RPC result 并回送相同 rpcId → Client result codec 验证并返回 CreateGoalResult @@ -421,7 +429,7 @@ ctx.api.goals.create(sessionId, request) Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 -Gateway 不处理逐方法权限、调用者身份、取消、幂等或长连接状态。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 @@ -475,6 +483,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 +- 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 - 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 ## 后果 @@ -499,4 +508,4 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 -Connection 提供 `AbortSignal`,但 Remote 业务签名没有取消参数。因此 Client 断连不会取消业务工作;取消仍作为后续工作,而不能由 transport handler 的形状暗示已经支持。 +支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index be40eeb20a..a5484d06c4 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/typert.md -typert.md: 9f5c63fc554a43fd0248ed08a64dcff566c83b58 -typert.zh.md: 2b74c8325a510ba39d134fa6d463dab273239772 +typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d +typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index 9f5c63fc55..da6e229ff6 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## Invocation descriptors -An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schemas, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 2b74c8325a..b3b0e88977 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -38,7 +38,7 @@ interface TypeRTLookupDefinition { ## 调用 descriptor -`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。 +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -100,6 +100,11 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ @@ -130,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求携带精确的具名 wire 字段;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -141,6 +146,8 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4fe2b12323..d8d067ce3e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2097,7 +2097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -2109,7 +2109,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvokeRemoteRequest', - declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', }, { name: 'JsonSchemaNode', diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 747aa65665..a1c22433f3 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/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/api-gateway/README.md -README.md: cc80bb19fec15414aa0857154a8a36fb4f642672 -README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295 +README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9 +README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index cc80bb19fe..9cb6e7e1c0 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -12,11 +12,13 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. +A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. + ## Client service: `ClientApi` (ctx key: `api`) `ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 6febb1cfe4..609580ceb7 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -12,11 +12,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 + ## Client 服务:`ClientApi`(ctx key:`api`) `ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 5cd8ab75d1..292df54152 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -223,9 +223,13 @@ class ClientApiService extends Service implements ClientApi { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) - if (values.length !== expected) { + const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 + if (values.length !== expected && !hasCallerSignal) { + const contract = descriptor.cancellation === undefined + ? `${String(expected)} argument(s)` + : `${String(expected)} business argument(s) plus an optional AbortSignal` throw new Error( - `client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`, + `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } const args: Record = {} @@ -248,7 +252,11 @@ class ClientApiService extends Service implements ClientApi { }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) - const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal) + const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined + const signal = callerSignal === undefined + ? token.abort.signal + : AbortSignal.any([token.abort.signal, callerSignal]) + const result = await connection.rpc.call('/api', endpoint, { args }, signal) if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) if (!result.ok) throw remoteFailure(endpoint, result.error) return parse(descriptor.result, result.value, endpoint, 'result') diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 64d5715719..c4a61cef8d 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -36,6 +36,7 @@ interface ResolvedBinding { } type ConnectionRpcResult = Awaited> +const NEVER_ABORTED_SIGNAL = new AbortController().signal /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -129,6 +130,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) const implementation = descriptor.implementation ?? descriptor.method const method = Reflect.get(receiver, implementation) as unknown if (typeof method !== 'function') { @@ -146,13 +148,12 @@ export class TypertGatewayService extends Service implements TypertGateway { private async dispatchRpc( endpoint: string, payload: unknown, - _signal: AbortSignal, + signal: AbortSignal, ): Promise { - // Remote methods have no cancellation parameter yet, so disconnects do not cancel business work. - return this.invokeRpc(endpoint, payload) + return this.invokeRpc(endpoint, payload, signal) } - private async invokeRpc(endpoint: string, payload: unknown): Promise { + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { try { const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { @@ -171,6 +172,7 @@ export class TypertGatewayService extends Service implements TypertGateway { namespace, method, args: payload.args, + signal, }) return { ok: true, value } } catch (error) { @@ -226,9 +228,22 @@ export class TypertGatewayService extends Service implements TypertGateway { endpoint: string, ): InvocationDescriptor { const names = methodParameterNames(binding.service, marker.method, endpoint) + const signalIndex = names.indexOf('signal') + if (signalIndex >= 0 && signalIndex !== names.length - 1) { + throw new TypertGatewayError( + 'signature-invalid', + endpoint, + 'SRC cancellation parameter signal must be the final parameter', + { field: 'signal' }, + ) + } + const cancellation = signalIndex >= 0 + ? { parameter: 'signal' as const } + : undefined + const businessNames = cancellation === undefined ? names : names.slice(0, -1) const parameters: InvocationParameterDescriptor[] = [] const wires = new Set() - for (const name of names) { + for (const name of businessNames) { const matches = this.ctx.typert.lookups.definitions() .filter(definition => definition.parameter === name) if (matches.length > 1) { @@ -295,6 +310,7 @@ export class TypertGatewayService extends Service implements TypertGateway { ...(marker.method === method ? {} : { implementation: marker.method }), invocation: receiver, parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: { mode: 'src-json' }, } } diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts index eea2bdc4f1..b7f36eb340 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/host/api-gateway/src/types.ts @@ -11,6 +11,8 @@ export interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal } /** Stable infrastructure and boundary failures emitted before or after business execution. */ diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 2e00d29c0d..3ad00ff0fc 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -17,11 +17,18 @@ declare module '@deepseek-ai/dsh-type-meta' { } interface TypeRTRemoteMap { - 'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'goals/create': ( + agentId: string, + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> } interface TypeRTRemoteContextMap { - 'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }> + 'fixture:goals/create': ( + request: { readonly objective: string }, + signal?: AbortSignal, + ) => Promise<{ readonly ref: string }> 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> } @@ -58,6 +65,7 @@ function directDescriptor(): InvocationDescriptor { source: 'json', codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema }, }], + cancellation: { parameter: 'signal' }, result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema }, } } @@ -114,6 +122,19 @@ describe('Client TypeRT API', () => { { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), ) + const callerAbort = new AbortController() + await expect(ctx.api.goals.create( + 'agent-1', + { objective: 'cancel me' }, + callerAbort.signal, + )).resolves.toEqual({ ref: 'goal-1' }) + const combinedSignal = call.mock.calls.at(-1)?.[3] + expect(combinedSignal).toBeInstanceOf(AbortSignal) + expect(combinedSignal).not.toBe(callerAbort.signal) + const cancellation = new Error('caller cancelled') + callerAbort.abort(cancellation) + expect(combinedSignal?.aborted).toBe(true) + expect(combinedSignal?.reason).toBe(cancellation) await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) @@ -299,10 +320,18 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const dispose = ctx.api.mount({ + package: '@fixture/goals', + descriptors: [descriptor, contextDescriptor()], + }) const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).goals + const rename = goals.rename as unknown as (...args: unknown[]) => Promise - await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') + await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) + .rejects.toThrow('got 4') + await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 4aeadeedb8..c05bfefb93 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -45,6 +45,7 @@ const emptyModel: TypertContribution['model'] = { class GoalService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'goals') readonly calls: string[] = [] + lastSignal: AbortSignal | undefined nextResult: unknown = undefined businessError: Error | undefined @@ -53,8 +54,9 @@ class GoalService extends Service { } @Remote - create(agent: FixtureAgent, request: { readonly title: string }): unknown { + create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { this.calls.push('create') + this.lastSignal = signal return { agentId: agent.id, title: request.title, @@ -224,6 +226,19 @@ class RestParameterService extends Service { } } +class NonFinalSignalService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'nonFinalSignal', { namespace: 'invalid-signal' }) + + constructor(ctx: Context) { + super(ctx, 'nonFinalSignal') + } + + @Remote + run(signal: AbortSignal, value: string): string { + return signal.aborted ? '' : value + } +} + class WrongBindingService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' }) @@ -334,13 +349,24 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) registerStrict(ctx, [createDescriptor()]) const caller = ctx.extend({ fixtureScope: 'direct-caller' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' }) expect(service.calls).toEqual(['create']) + expect(service.lastSignal).toBe(abort.signal) + + await expect(caller.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'again' } }, + })).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' }) + expect(service.lastSignal).toBeInstanceOf(AbortSignal) + expect(service.lastSignal?.aborted).toBe(false) }) it('resolves strict Remote Context identity without adding a business argument', async () => { @@ -358,16 +384,19 @@ describe('TypertGatewayService', () => { }) it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => { - const { ctx } = await setup() + const { ctx, service } = await setup() const agent = { id: 'agent-1' } registerAgentLookup(ctx, agent) const caller = ctx.extend({ fixtureScope: 'direct-src' }) + const abort = new AbortController() await expect(caller.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, + signal: abort.signal, })).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' }) + expect(service.lastSignal).toBe(abort.signal) }) it('does not downgrade an observed SRC lookup after its provider unloads', async () => { @@ -605,6 +634,7 @@ describe('TypertGatewayService', () => { { plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } }, { plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } }, { plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } }, + { plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } }, ] as const for (const testCase of cases) { const ctx = await setupGateway() @@ -874,7 +904,8 @@ describe('TypertGatewayService', () => { expect(connection.matches?.('goals')).toBe(false) expect(connection.matches?.('goals/missing')).toBe(false) expect(connection.matches?.('legacy/list')).toBe(false) - const signal = new AbortController().signal + const abort = new AbortController() + const signal = abort.signal const handler = connection.handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') await expect(handler('goals/create', { @@ -883,6 +914,10 @@ describe('TypertGatewayService', () => { ok: true, value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, }) + const service = rawGoalService(ctx) + expect(service.lastSignal).toBe(signal) + abort.abort(new Error('client disconnected')) + expect(service.lastSignal?.aborted).toBe(true) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -904,7 +939,6 @@ describe('TypertGatewayService', () => { expect(result.error.message).toContain('plain-object args field') } - const service = rawGoalService(ctx) service.businessError = 'non-error failure' as unknown as Error await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ ok: false, @@ -1099,6 +1133,7 @@ function createDescriptor(): InvocationDescriptor { })), }, ], + cancellation: { parameter: 'signal' }, result: strictCodec('@fixture/gateway#CreateResult', z.object({ agentId: z.string(), title: z.string(), diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index f430d757fb..87a23f17f5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -963,8 +963,9 @@ class FaceAnalyzer { const lookups = this.lookupDeclarations() const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup])) const parameters: InvocationParameterModel[] = [] + let cancellation: InvocationModel['cancellation'] const wires = new Set() - for (const parameter of method.parameters) { + for (const [parameterIndex, parameter] of method.parameters.entries()) { if (!ts.isIdentifier(parameter.name)) { this.fail(parameter, 'Remote parameters must use identifier bindings') } @@ -973,6 +974,18 @@ class FaceAnalyzer { if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional') if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter') const authoredType = this.requiredType(parameter, parameter.type, 'parameter') + const cancellationName = parameter.name.text === 'signal' + const cancellationType = this.isGlobalAbortSignal(authoredType) + if (cancellationName || cancellationType) { + if (!cancellationName || !cancellationType) { + this.fail(parameter, 'Remote cancellation must use a parameter named signal with the global AbortSignal type') + } + if (parameterIndex !== method.parameters.length - 1) { + this.fail(parameter, 'Remote cancellation signal must be the final parameter') + } + cancellation = { parameter: 'signal' } + continue + } const hostSymbol = this.symbolAtType(authoredType) const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol)) let modeled: InvocationParameterModel @@ -1065,6 +1078,7 @@ class FaceAnalyzer { invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, + ...(cancellation === undefined ? {} : { cancellation }), result: this.remoteBoundary( resultType, `${registration.name}#${binding.namespace}/${exportedMethod}:result`, @@ -1181,6 +1195,13 @@ class FaceAnalyzer { return resultType } + private isGlobalAbortSignal(type: ts.TypeNode): boolean { + const symbol = this.symbolAtType(type) + if (symbol?.name !== 'AbortSignal') return false + return symbol.declarations?.some(declaration => + isStandardLibraryFile(declaration.getSourceFile().fileName)) === true + } + private lookupDeclarations(): readonly StaticLookupDeclaration[] { if (this.staticLookups !== undefined) return this.staticLookups const byKey = new Map() diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 63b1ee7ace..c8b9ab4195 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -315,6 +315,9 @@ export class FaceModelEmitter { lines.push(' },') }) lines.push(' ],') + if (invocation.cancellation !== undefined) { + lines.push(" cancellation: { parameter: 'signal' },") + } lines.push(` result: ${indent(strictCodec( invocation.result, schemas.boundary(resultBoundaryKey(invocation)), @@ -459,6 +462,7 @@ export class FaceModelEmitter { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) return `(${parameters.join(', ')}) => Promise<${result}>` } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 7f15c8407c..81bc6a91a1 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -140,6 +140,9 @@ export interface InvocationModel { readonly wire: string } readonly parameters: readonly InvocationParameterModel[] + readonly cancellation?: { + readonly parameter: 'signal' + } readonly result: RemoteBoundaryModel readonly location: SourceLocation } diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 816a13a5a7..115b3b87a6 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -12,7 +12,8 @@ export class GoalService { readonly typertGateway = bindTypeRTGateway(this, 'goals') @Remote - async create(agent: Agent, request: CreateGoalRequest): Promise { + async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() return { ref: `${agent.id}:${request.title}` } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index cb6e6e6060..d5838f39ce 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -16,6 +16,7 @@ interface RuntimeSchema { interface RuntimeDescriptor { readonly id: string + readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string readonly codec: { readonly schema: RuntimeSchema } @@ -83,6 +84,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' }, }, ], + cancellation: { parameter: 'signal' }, result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, }) expect(model.invocations[1]).toMatchObject({ @@ -107,12 +109,12 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( - "'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise", + "'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:') expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73") expect(artifact?.remote?.dts).toContain( - "'agent:goals/create': (request: CreateGoalRequest) => Promise", + "'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise", ) expect(artifact?.remote?.dts).toContain( "'agent:goals/rename': (request: RenameGoalRequest) => Promise", @@ -124,6 +126,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote') const create = generated.TYPERT_REMOTE.descriptors[0] + expect(create?.cancellation).toEqual({ parameter: 'signal' }) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true) expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) @@ -234,8 +237,8 @@ export type GenericResult = { edit: (source: string) => source .replace('export class GoalService', 'export abstract class GoalService') .replace( - ' async create(agent: Agent, request: CreateGoalRequest): Promise {\n return { ref: `${agent.id}:${request.title}` }\n }', - ' abstract create(agent: Agent, request: CreateGoalRequest): Promise', + ' async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise {\n signal.throwIfAborted()\n return { ref: `${agent.id}:${request.title}` }\n }', + ' abstract create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise', ), message: 'Remote methods must have a concrete implementation', }, @@ -267,6 +270,24 @@ export type GenericResult = { edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), message: 'Remote parameters cannot be optional', }, + { + name: 'wrong cancellation type', + edit: (source: string) => source.replace('signal: AbortSignal', 'signal: string'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'wrong cancellation name', + edit: (source: string) => source.replace('signal: AbortSignal', 'abort: AbortSignal'), + message: 'cancellation must use a parameter named signal with the global AbortSignal type', + }, + { + name: 'non-final cancellation', + edit: (source: string) => source.replace( + 'agent: Agent, request: CreateGoalRequest, signal: AbortSignal', + 'agent: Agent, signal: AbortSignal, request: CreateGoalRequest', + ), + message: 'cancellation signal must be the final parameter', + }, ])('rejects $name', ({ edit, message }) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', edit) @@ -399,12 +420,14 @@ declare const create: TypeRTRemoteMap['goals/create'] declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] const created: Promise = create('agent-1', { title: 'ship' }) +const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) declare const ctx: { api: TypeRTRemoteNamespaceMap } const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) void contribution void created +void cancellable void createdScoped void renamed void navigated diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index efe0fa6f94..575d066e0d 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -226,6 +226,12 @@ function requireInvocation(pkgName: string, value: unknown): void { parameters.set(wire, parameter) requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`) } + if (invocation.cancellation !== undefined) { + const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`) + if (cancellation.parameter !== 'signal') { + throw new Error(`typert-loader: ${pkgName} invocation "${id}" cancellation parameter must be "signal"`) + } + } if (invocation.scope !== undefined) { if (receiver.kind !== 'direct') { throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index ec407f82d9..750cc92e57 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -83,6 +83,7 @@ function invocationTypertSource(pkgName: string): string { ' name: \'request\', wire: \'request\', source: \'json\',', ` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`, ' }],', + " cancellation: { parameter: 'signal' },", ` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`, ' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },', ' }],', @@ -157,6 +158,7 @@ describe('typert loader', () => { id: '@fixture/invocation#goals/create', invocation: { kind: 'direct' }, parameters: [{ wire: 'request', source: 'json' }], + cancellation: { parameter: 'signal' }, sourceLocation: { file: 'src/index.ts', line: 8, column: 3 }, }) expect(descriptor?.parameters[0]?.codec.mode).toBe('strict') @@ -502,6 +504,9 @@ describe('validateTypertManifest', () => { const descriptor = strictInvocation() const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } } + expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations) + .toEqual([cancellable]) const scoped = { ...descriptor, scope: { context: 'agent', wire: 'agentId' }, @@ -526,6 +531,14 @@ describe('validateTypertManifest', () => { ...base, invocations: [{ ...descriptor, result: { mode: 'src-json' } }], })).toThrow('result codec must use a strict codec') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: null }], + })).toThrow('cancellation must be an object') + expect(() => validateTypertManifest('pkg', { + ...base, + invocations: [{ ...descriptor, cancellation: { parameter: 'abort' } }], + })).toThrow('cancellation parameter must be "signal"') expect(() => validateTypertManifest('pkg', { ...base, invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }], diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 6749cdbeb9..229dc7affc 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,6 +562,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } + if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) + } if (descriptor.scope !== undefined) { if (descriptor.invocation.kind !== 'direct') { throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 51e7594749..5603ce8954 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -411,6 +411,7 @@ describe('TypertRegistry', () => { ...invocation('@fixture/remote#strict'), implementation: 'remoteExportCreate', parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + cancellation: { parameter: 'signal' }, result: strict, } const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) @@ -420,6 +421,10 @@ describe('TypertRegistry', () => { [{ ...invocation(), id: '' }, 'invocation id'], [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + cancellation: { parameter: 'abort' } as unknown as { readonly parameter: 'signal' }, + }, 'cancellation parameter'], [{ ...invocation(), parameters: [ diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 90d93152b7..9751c4c088 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/type-meta/README.md -README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43 -README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e +README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae +README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 9dd8dadd07..95716446c0 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -11,6 +11,8 @@ Compiler-independent declarations shared by business packages, generated TypeRT - `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. +A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type. + Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. ## TypeRT protocol diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5716f56d98..0d30b31222 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -11,6 +11,8 @@ - `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 +Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。 + 装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 ## TypeRT 协议 diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index f9ed7ffa97..6de5c7f823 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -157,6 +157,11 @@ export interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** Transport cancellation injected after business parameters instead of entering wire args. */ + readonly cancellation?: { + /** Reserved final Host method parameter. */ + readonly parameter: 'signal' + } /** Codec for the resolved method result. */ readonly result: TypeRTCodec /** Source declaration used only for diagnostics. */ From 1ea5507bf893cae71de68da12d534d6d5dca6d03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:43:31 +0800 Subject: [PATCH 41/88] fix(typert): close remote gateway review gaps --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/goal/goal/tests/goal.spec.ts | 16 ++++++++ .../host/api-gateway/tests/gateway.spec.ts | 12 ++++-- packages/typert/registry/src/service.ts | 3 +- scripts/run-gates.ts | 1 + vitest.config.ts | 30 +++------------ vitest.e2e.config.ts | 4 +- vitest.shared.ts | 37 +++++++++++++++++++ vitest.snapshot.config.ts | 4 +- 11 files changed, 77 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index bd83c38a3e..57e1054dfc 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af -2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 +2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380 +2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 4268539ecf..552e910b40 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -452,7 +452,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. -Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision. +Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index f9f426f2fb..18b8c1687d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -452,7 +452,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H 已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 -Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。 +Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 ## Alternatives considered diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 38eea7cf61..2dd5885cc7 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -245,6 +245,22 @@ describe('GoalService creation and replay', () => { }) describe('GoalService mutations', () => { + it('exposes the supported mutation sequence through Remote wrappers', async () => { + const { ctx, agent } = await harness() + const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' }) + const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' }) + const paused = ctx.goals.remoteExportPause(agent, edited) + const resumed = ctx.goals.remoteExportResume(agent, paused) + const completed = ctx.goals.remoteExportComplete(agent, resumed) + const cleared = ctx.goals.remoteExportClear(agent, completed) + + expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 }) + expect(paused).toMatchObject({ phase: 'paused', revision: 3 }) + expect(resumed).toMatchObject({ phase: 'active', revision: 4 }) + expect(completed).toMatchObject({ phase: 'complete', revision: 5 }) + expect(cleared).toEqual({ id: created.ref.id, revision: 6 }) + }) + it('edits with compare-and-set revisions and rejects empty edits', async () => { const { ctx, agent } = await harness() const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 }) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index c05bfefb93..6558a7ca47 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -998,14 +998,16 @@ describe('TypertGatewayService', () => { }), }) expect(invalid.status).toBe(200) - await expect(invalid.json()).resolves.toMatchObject({ + const invalidBody = await invalid.json() as unknown + expect(invalidBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-invalid', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('plain-object args field') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(invalidBody)).toContain('plain-object args field') await removeStrict() strictActive = false @@ -1020,14 +1022,16 @@ describe('TypertGatewayService', () => { }), }) expect(withdrawn.status).toBe(200) - await expect(withdrawn.json()).resolves.toMatchObject({ + const withdrawnBody = await withdrawn.json() as unknown + expect(withdrawnBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-withdrawn', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) expect(unclaimed.status).toBe(404) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 229dc7affc..d04f38cde5 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,7 +562,8 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } - if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + const cancellation = descriptor.cancellation as { readonly parameter: string } | undefined + if (cancellation !== undefined && cancellation.parameter !== 'signal') { throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) } if (descriptor.scope !== undefined) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f7669eac7e..6d6a76e476 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -601,6 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', + 'packages/client/remotes/tests/built-lib.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/vitest.config.ts b/vitest.config.ts index 4c4c668b94..56a5a1575b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,8 +3,7 @@ import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' -import ts from 'typescript' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' // Prints exact `path:line:col` records for every uncovered statement, branch @@ -18,29 +17,6 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc // map applies to every test file. paths must win over package exports so built // lib/ never loads a second module-singleton copy. const pathsPlugin = (): ReturnType => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) -const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m - -const standardDecoratorPlugin = () => ({ - name: 'dsh-standard-decorators', - enforce: 'pre' as const, - transform(code: string, id: string) { - const file = id.split('?', 1)[0]! - if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return - const result = ts.transpileModule(code, { - fileName: file, - compilerOptions: { - target: ts.ScriptTarget.ES2024, - module: ts.ModuleKind.ESNext, - jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, - sourceMap: true, - }, - }) - return { - code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), - map: result.sourceMapText, - } - }, -}) const windowsUnsupportedPackages = process.platform === 'win32' ? [ @@ -203,6 +179,10 @@ export default defineConfig({ 'packages/client/hmr/src/invariant.ts', 'packages/client/connection/src/index.ts', 'packages/client/connection/src/http-bridge.ts', + // This assembly imports generated Host-for-Client code that exists + // only in lib; the post-build built-bin smoke executes both entries. + 'packages/client/remotes/src/index.ts', + 'packages/client/remotes/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/connection/src/client/fixture.ts', diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index d8e6aa53a7..f898d2d9da 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,6 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' // Real-API suite, separate because it spends tokens. Each test self-skips without // its provider credential for keyless CI; credentialed workflows preflight the @@ -36,7 +36,7 @@ export default defineConfig({ // Built-artifact e2e suites are unaffected: their built-ness lives in // subprocesses and createRequire lookups, which bypass vite resolution // entirely. - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], diff --git a/vitest.shared.ts b/vitest.shared.ts index 506fabb380..7c6ca2bee8 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -1,5 +1,42 @@ +import ts from 'typescript' + +const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m + /** * Worker arguments that keep process-wide Web Storage from shadowing jsdom storage. * Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag. */ export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : [] + +/** + * Transform standard TypeScript decorators before Vite's default parser sees source files. + * @returns a pre-transform Vite plugin shared by source-mode test configurations. + */ +export function standardDecoratorPlugin() { + return { + name: 'dsh-standard-decorators', + enforce: 'pre' as const, + transform(code: string, id: string) { + const file = id.split('?', 1)[0]! + if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, + sourceMap: true, + }, + }) + return { + code: result.outputText + .replace( + /^(\s*)(__esDecorate\()/gmu, + '$1/* v8 ignore next -- compiler-synthetic decorator accessors have no source behavior */ $2', + ) + .replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, + } +} diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 455ecfb4d4..cfa7d12e17 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,7 +1,7 @@ import { availableParallelism } from 'node:os' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5 @@ -40,7 +40,7 @@ export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the tsconfig.base.json paths facade; the native option cannot do // this (the root tsconfig is a solution file with no paths). - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], From e8f2ab89bb98c81374f570386793173f4c718aa2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:04:25 +0800 Subject: [PATCH 42/88] refactor(typert): bind remote services through base class --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 24 ++++---- ...026-08-02-typert-remote-method-calls.zh.md | 24 ++++---- packages/goal/goal/src/index.ts | 9 +-- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 2 +- packages/host/api-gateway/README.zh.md | 2 +- packages/typert/generator/src/analyzer.ts | 50 ++++++++++++++-- .../remote-model/packages/remote/src/index.ts | 8 ++- .../fixtures/remote-model/type-meta.d.ts | 13 ++++ .../generator/tests/remote-model.spec.ts | 60 +++++++++++++++++-- packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 7 ++- packages/typert/type-meta/README.zh.md | 7 ++- packages/typert/type-meta/src/index.ts | 18 ++++++ .../type-meta/tests/fixtures/source-launch.ts | 11 ++-- .../typert/type-meta/tests/type-meta.spec.ts | 27 +++++++-- 17 files changed, 213 insertions(+), 61 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 57e1054dfc..3808a8d363 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380 -2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d +2026-08-02-typert-remote-method-calls.md: ade8eb827ae765677be8dcdb0ffec965c67bc4ab +2026-08-02-typert-remote-method-calls.zh.md: 2de887a2a0e46148fbb2b5ac52cfd7e3b2305b8d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 552e910b40..ade8eb827a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -16,7 +16,7 @@ The Host and Browser Client use separate TypeScript Programs because each side a ## Decision -A business Service declares callable methods with `@Remote` or `@RemoteContext()` and explicitly joins the Gateway through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. +A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. @@ -26,7 +26,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Component | Cordis service | Responsibility | |---|---|---| -| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | Decorators, bindings, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | `GatewayService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | @@ -43,8 +43,10 @@ The Host Gateway does not depend on concrete implementations of `ctx.agents`, `c Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: ```text -export class GoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { // Existing business method remains unchanged. @@ -57,13 +59,15 @@ export class GoalService extends Service { } ``` -`goals` is an explicit Cordis service key and is the default wire namespace. Override it through an option to `bindTypeRTGateway()` only when the protocol namespace genuinely needs to differ from the service key. +`goals` is the explicit Cordis service key passed to `super()` and is the default wire namespace. Pass a `namespace` option as the third argument only when the protocol namespace genuinely needs to differ from the service key. Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: ```text -export class ScopedGoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class ScopedGoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @RemoteContext('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { @@ -74,17 +78,17 @@ export class ScopedGoalService extends Service { An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides declaration protocols for decorators, `bindTypeRTGateway()`, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. `typertGateway` is the sole explicit marker that a Service has joined the Gateway, making this capability visible on both the business class and its runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. -In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. Generation neither rewrites business source nor secretly supplies generated arguments to `bindTypeRTGateway()`. +In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `GatewayService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. ## Lookup and Remote Context registration diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 18b8c1687d..2de887a2a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -16,7 +16,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 ## 决策 -业务 Service 通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法,并通过 `bindTypeRTGateway()` 显式加入 Gateway。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 +业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 @@ -26,7 +26,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | 组件 | Cordis 服务 | 职责 | |---|---|---| -| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | decorator、binding、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | `GatewayService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | @@ -43,8 +43,10 @@ Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.http 普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: ```text -export class GoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { // Existing business method remains unchanged. @@ -57,13 +59,15 @@ export class GoalService extends Service { } ``` -`goals` 是明确的 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过 `bindTypeRTGateway()` 的选项覆盖。 +`goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。 需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: ```text -export class ScopedGoalService extends Service { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class ScopedGoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @RemoteContext('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { @@ -74,17 +78,17 @@ export class ScopedGoalService extends Service { 同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 decorator、`bindTypeRTGateway()`、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。`typertGateway` 是 Service 加入 Gateway 的唯一显式标志,使业务类和运行时实例都能直接看出这项能力。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 -LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。生成过程不改写业务源码,也不向 `bindTypeRTGateway()` 偷注生成参数。 +LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。它接受 `GatewayService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 ## Lookup 与 Remote Context 注册 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 0997aad0dc..312e3a70d9 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,14 +5,14 @@ */ import { randomUUID } from 'node:crypto' -import { Context, Service } from 'cordis' +import { Context } from 'cordis' import z from 'schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { Remote, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' // Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { @@ -180,7 +180,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason { } /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ -export class GoalService extends Service { +export class GoalService extends GatewayService { static inject = ['agents'] static Config: z = z.object({ @@ -190,9 +190,6 @@ export class GoalService extends Service { private readonly resolved: ResolvedConfig private readonly caches = new WeakMap() - /** Explicit participation in the TypeRT Gateway under the Cordis service key. */ - readonly typertGateway = bindTypeRTGateway(this, 'goals') - constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index a1c22433f3..273a493c24 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/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/api-gateway/README.md -README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9 -README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1 +README.md: 43e8f464e2a2790d05628a7fba61143a6a5ab26a +README.zh.md: 761045d0c1afc17dfc230f9f45849c46e4e579fc diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 9cb6e7e1c0..43e8f464e2 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -6,7 +6,7 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) -`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services declare participation with `bindTypeRTGateway()` and `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md). +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 609580ceb7..761045d0c1 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -6,7 +6,7 @@ ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务调用 `bindTypeRTGateway()` 并使用 [`dsh-type-meta`](../../typert/type-meta/README.md) 提供的 `@Remote` 或 `@RemoteContext` 装饰器,以显式声明接入。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 87a23f17f5..ecc7d8aa6b 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -142,7 +142,7 @@ interface StaticContextDeclaration { interface GatewayBinding { readonly service: string readonly namespace: string - readonly site: ts.PropertyDeclaration + readonly site: ts.Node } type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode @@ -927,7 +927,10 @@ class FaceAnalyzer { if (first === undefined) continue const binding = this.gatewayBinding(statement) if (binding === undefined) { - this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)') + this.fail( + first.method, + 'Remote methods require GatewayService or readonly typertGateway = bindTypeRTGateway(this, serviceKey)', + ) } for (const { method, invocation } of marked) { result.push(this.invocationModel(registration, binding, method, invocation)) @@ -1089,6 +1092,15 @@ class FaceAnalyzer { } private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const field = this.gatewayFieldBinding(declaration) + const base = this.gatewayServiceBinding(declaration) + if (field !== undefined && base !== undefined) { + this.fail(field.site, 'GatewayService subclasses must not declare a second typertGateway binding') + } + return field ?? base + } + + private gatewayFieldBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration => ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway') const [property, duplicate] = candidates @@ -1111,10 +1123,38 @@ class FaceAnalyzer { if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) { this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this') } + return this.gatewayBindingArguments(call, property) + } + + private gatewayServiceBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined { + const heritage = (declaration.heritageClauses ?? []) + .filter(clause => clause.token === ts.SyntaxKind.ExtendsKeyword) + .flatMap(clause => [...clause.types]) + .find(type => this.isTypeMetaSymbol(type.expression, 'GatewayService')) + if (heritage === undefined) return undefined + + const constructor = declaration.members.find(ts.isConstructorDeclaration) + if (constructor?.body === undefined) { + this.fail(heritage, 'GatewayService subclasses must declare a constructor with super(ctx, serviceKey)') + } + const call = constructor.body.statements.flatMap((statement) => { + if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return [] + return statement.expression.expression.kind === ts.SyntaxKind.SuperKeyword ? [statement.expression] : [] + })[0] + if (call === undefined) { + this.fail(constructor, 'GatewayService constructor must call super(ctx, serviceKey) directly') + } + if (call.arguments.length < 2 || call.arguments.length > 3) { + this.fail(call, 'GatewayService super() requires context, service key, and an optional options object') + } + return this.gatewayBindingArguments(call, heritage) + } + + private gatewayBindingArguments(call: ts.CallExpression, site: ts.Node): GatewayBinding { const serviceArgument = call.arguments[1] - if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal') + if (serviceArgument === undefined) this.fail(call, 'Gateway service key must be a string literal') const service = stringLiteralValue(serviceArgument) - if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal') + if (service === undefined) this.fail(serviceArgument, 'Gateway service key must be a string literal') let namespace = service const options = call.arguments[2] if (options !== undefined) { @@ -1133,7 +1173,7 @@ class FaceAnalyzer { } if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') - return { service, namespace, site: property } + return { service, namespace, site } } private remoteMarker( diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 115b3b87a6..4aa51ec433 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -1,4 +1,4 @@ -import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' import type { Agent } from '@fixture/domain' import type { CreateGoalRequest, @@ -8,8 +8,10 @@ import type { } from './types.ts' /** Remote-only business Service with no Cordis declaration merge. */ -export class GoalService { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +export class GoalService extends GatewayService { + constructor() { + super(undefined, 'goals') + } @Remote async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise { diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index f8e84bbe90..91daea98c2 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -26,6 +26,19 @@ declare module '@deepseek-ai/dsh-type-meta' { readonly descriptors: readonly unknown[] } + export abstract class GatewayService { + readonly typertGateway: { + readonly service: GatewayService + readonly serviceKey: string + readonly namespace: string + } + protected constructor( + ctx: unknown, + serviceKey: string, + options?: { readonly namespace?: string }, + ) + } + export function bindTypeRTGateway( service: Service, serviceKey: string, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index d5838f39ce..268645ca73 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -219,8 +219,56 @@ export type GenericResult = { it.each([ { name: 'missing binding', - edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''), - message: 'Remote methods require readonly typertGateway', + edit: (source: string) => source.replace( + "export class GoalService extends GatewayService {\n constructor() {\n super(undefined, 'goals')\n }", + 'export class GoalService {', + ), + message: 'Remote methods require GatewayService', + }, + { + name: 'dynamic GatewayService key', + edit: (source: string) => source.replace( + " constructor() {\n super(undefined, 'goals')\n }", + ' constructor(serviceKey: string) {\n super(undefined, serviceKey)\n }', + ), + message: 'Gateway service key must be a string literal', + }, + { + name: 'GatewayService without a constructor', + edit: (source: string) => source.replace( + " constructor() {\n super(undefined, 'goals')\n }\n\n", + '', + ), + message: 'GatewayService subclasses must declare a constructor', + }, + { + name: 'GatewayService without a direct super call', + edit: (source: string) => source.replace( + " super(undefined, 'goals')", + ' void undefined', + ), + message: 'GatewayService constructor must call super', + }, + { + name: 'GatewayService super call without a service key', + edit: (source: string) => source.replace( + " super(undefined, 'goals')", + ' super(undefined)', + ), + message: 'GatewayService super\\(\\) requires context, service key', + }, + { + name: 'duplicate GatewayService field binding', + edit: (source: string) => source + .replace( + 'import { GatewayService, Remote, RemoteContext }', + 'import { GatewayService, Remote, RemoteContext, bindTypeRTGateway }', + ) + .replace( + 'export class GoalService extends GatewayService {', + "export class GoalService extends GatewayService {\n readonly typertGateway = bindTypeRTGateway(this, 'goals')", + ), + message: 'GatewayService subclasses must not declare a second typertGateway binding', }, { name: 'private method', @@ -351,8 +399,10 @@ export type GenericResult = { it('rejects duplicate endpoints across Remote services', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => `${source} -export class DuplicateGoalService { - readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' }) +export class DuplicateGoalService extends GatewayService { + constructor() { + super(undefined, 'duplicate', { namespace: 'goals' }) + } @Remote create(request: CreateGoalRequest): CreateGoalResult { @@ -521,7 +571,7 @@ ctx.api.goals.create('agent-1', { title: 'must not compile' }) if (config.error !== undefined) throw new Error(formatDiagnostics([config.error])) const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath) const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options)) - expect(diagnostics).toHaveLength(1) + expect(diagnostics, formatDiagnostics(diagnostics)).toHaveLength(1) expect(diagnostics[0]?.code).toBe(2339) expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist") } diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 9751c4c088..a3e0643ace 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/type-meta/README.md -README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae -README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3 +README.md: 245df305efcf711486b2d3f32e40a8b415f2682e +README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 95716446c0..245df305ef 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -2,18 +2,19 @@ English | [中文](README.zh.md) -Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service. +Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns the Remote Service base, decorators, explicit binding fallback, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or register a concrete Cordis service. ## Remote declarations - `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. - `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. -- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace. +- `GatewayService` binds the Cordis key passed to `super(ctx, serviceKey, options?)` to the same default wire namespace. +- `bindTypeRTGateway(this, serviceKey, options?)` provides the same visible, frozen binding for a Service that cannot inherit from `GatewayService`. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type. -Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field. +Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. A `GatewayService` exposes the same public readonly `typertGateway` binding that the explicit helper returns. ## TypeRT protocol diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 0d30b31222..592aa5d027 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -2,18 +2,19 @@ [English](README.md) | 中文 -该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。 +该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote Service 基类、装饰器、显式 binding 回退、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不注册具体 Cordis 服务。 ## Remote 声明 - `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 - `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 -- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。 +- `GatewayService` 将 `super(ctx, serviceKey, options?)` 接收的 Cordis key 同时绑定为默认 wire namespace。 +- `bindTypeRTGateway(this, serviceKey, options?)` 为无法继承 `GatewayService` 的 Service 提供同样可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。 -装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。 +装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。`GatewayService` 会暴露与显式 helper 相同的 public readonly `typertGateway` 绑定。 ## TypeRT 协议 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 92438ee0fa..4d4457b5be 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-type-meta */ +import { Service, type Context } from 'cordis' import type { TypeRTContextMap } from './types.ts' export type { @@ -104,6 +105,23 @@ export function bindTypeRTGateway( return Object.freeze({ service, serviceKey, namespace }) } +/** Cordis Service base that exposes its registered name through TypeRT Gateway. */ +export abstract class GatewayService extends Service { + /** Visible binding consumed by the Gateway's source-mode discovery. */ + readonly typertGateway: TypeRTGatewayBinding + + /** + * Register the Service and bind the same key to TypeRT Gateway. + * @param ctx - owning Cordis Context. + * @param serviceKey - exact Cordis service key and default wire namespace. + * @param options - optional distinct wire namespace. + */ + protected constructor(ctx: Context, serviceKey: string, options: TypeRTGatewayBindingOptions = {}) { + super(ctx, serviceKey) + this.typertGateway = bindTypeRTGateway(this, this.name, options) + } +} + /** * Mark one public instance method as a direct Remote invocation. * @param _method - decorated method; retained only by the class itself. diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index 68f886dff1..b13a80796d 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -1,12 +1,15 @@ +import { Context } from 'cordis' import { - bindTypeRTGateway, + GatewayService, Remote, RemoteContext, remoteMethods, } from '@deepseek-ai/dsh-type-meta' -class Goals { - readonly typertGateway = bindTypeRTGateway(this, 'goals') +class Goals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @Remote create(value: string): string { @@ -19,7 +22,7 @@ class Goals { } } -const methods = remoteMethods(new Goals()) +const methods = remoteMethods(new Goals(new Context())) const actual = JSON.stringify(methods) const expected = JSON.stringify([ { method: 'create', invocation: { kind: 'direct' } }, diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index f25c367914..8a2a4372ce 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -1,8 +1,10 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { bindTypeRTGateway, + GatewayService, Remote, RemoteContext, remoteMethods, @@ -16,9 +18,11 @@ declare module '@deepseek-ai/dsh-type-meta' { } describe('type-meta Remote declarations', () => { - it('executes standard decorator syntax through the Vitest source transform', () => { - class Goals { - readonly typertGateway = bindTypeRTGateway(this, 'goals') + it('binds a GatewayService name and executes decorators through the Vitest source transform', async () => { + class Goals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } @Remote create(value: string): string { @@ -31,11 +35,26 @@ describe('type-meta Remote declarations', () => { } } - const goals = new Goals() + class NamespacedGoals extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'internalGoals', { namespace: 'goals' }) + } + } + + const ctx = new Context() + const goals = new Goals(ctx) + const namespaced = new NamespacedGoals(ctx) + expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' }) + expect(namespaced.typertGateway).toEqual({ + service: namespaced, + serviceKey: 'internalGoals', + namespace: 'goals', + }) expect(remoteMethods(goals)).toEqual([ { method: 'create', invocation: { kind: 'direct' } }, { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, ]) + await ctx.fiber.dispose() }) it('executes standard decorator syntax through the TSX source launcher', () => { From ede278d0c79ff07a53df018d782ed4753356e308 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:16:07 +0800 Subject: [PATCH 43/88] fix(connection): mint RPC ids on insecure origins --- packages/client/connection/src/client/fixture.ts | 3 ++- .../client/connection/src/client/random-uuid.ts | 14 ++++++++++++++ packages/client/connection/src/client/rpc.ts | 3 ++- .../client/connection/tests/client-apply.spec.ts | 9 ++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 packages/client/connection/src/client/random-uuid.ts diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 31747bb311..e13c0a19f6 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -35,10 +35,11 @@ import type { } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' +import { randomUuid } from './random-uuid.ts' /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

(payload: P): RpcRequest

{ - return { rpcId: RpcId(crypto.randomUUID()), payload } + return { rpcId: RpcId(randomUuid()), payload } } function text(t: string): ContentBlock[] { diff --git a/packages/client/connection/src/client/random-uuid.ts b/packages/client/connection/src/client/random-uuid.ts new file mode 100644 index 0000000000..dc3106bd86 --- /dev/null +++ b/packages/client/connection/src/client/random-uuid.ts @@ -0,0 +1,14 @@ +/** Browser-safe UUID generation for client-side wire correlation. */ + +/** + * Generate an RFC 4122 version 4 UUID without requiring a secure context. + * @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins. + */ +export function randomUuid(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40) + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80) + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 0c12149d7b..7883f2a9d3 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -6,6 +6,7 @@ import { type ClientRequest, } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientConnectionRpc } from '../rpc.ts' +import { randomUuid } from './random-uuid.ts' const INTERNAL_BASE = 'http://dsh.internal' const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ @@ -19,7 +20,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc { return { async call(channel, endpoint, payload, signal) { assertTarget(channel, endpoint) - const rpcId = RpcId(crypto.randomUUID()) + const rpcId = RpcId(randomUuid()) const message: ClientRequest = { type: 'client-request', rpcId, diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6bf9c26b46..41e8e9b0e2 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -204,8 +204,13 @@ describe('connection client apply', () => { expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) - it('carries RPC calls over the shared API channel with rpcId echo validation', async () => { + it('carries RPC calls without requiring secure-context randomUUID', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } + vi.stubGlobal('crypto', { + getRandomValues(bytes: Uint8Array) { + return bytes.fill(0) + }, + }) const handle = await mount() const original = globalThis.fetch const seen: { url: string; body: unknown }[] = [] @@ -225,11 +230,13 @@ describe('connection client apply', () => { .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } }) } finally { globalThis.fetch = original + vi.unstubAllGlobals() } expect(seen).toHaveLength(1) expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create') expect(seen[0]?.body).toMatchObject({ type: 'client-request', + rpcId: '00000000-0000-4000-8000-000000000000', method: 'goals/create', payload: { args: { agentId: 'agent-1' } }, }) From 2f619b1b88ebd0ffc054ff24852c8775d98946a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:59:34 +0800 Subject: [PATCH 44/88] docs: document TypeRT API-Gateway --- docs/api-gateway.i18n.yaml | 6 ++ docs/api-gateway.md | 157 ++++++++++++++++++++++++++++++++++++ docs/api-gateway.zh.md | 157 ++++++++++++++++++++++++++++++++++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/development.i18n.yaml | 4 +- docs/development.md | 2 + docs/development.zh.md | 2 + 9 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 docs/api-gateway.i18n.yaml create mode 100644 docs/api-gateway.md create mode 100644 docs/api-gateway.zh.md diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml new file mode 100644 index 0000000000..87abb10c88 --- /dev/null +++ b/docs/api-gateway.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/api-gateway.md +api-gateway.md: 76af93880d278a17dc46370fd5065fdcdadb9fb6 +api-gateway.zh.md: d447cea6b64bf88084f86a210a5f654bd9445d6c diff --git a/docs/api-gateway.md b/docs/api-gateway.md new file mode 100644 index 0000000000..76af93880d --- /dev/null +++ b/docs/api-gateway.md @@ -0,0 +1,157 @@ +# API Gateway + +English | [中文](api-gateway.zh.md) + +This is the current-state reference for the TypeRT API Gateway. It describes how business services declare unary Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. + +## Programming model + +Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. + +`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to the current live object before invoking the business method. + +`@RemoteContext(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. + +Services normally extend `GatewayService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. + +```ts +import type { Agent } from '@deepseek-ai/dsh-agent' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import type { Context } from 'cordis' + +export interface CreateGoalRequest { + objective: string +} + +export interface CreateGoalResult { + accepted: boolean +} + +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote('create') + createForClient( + agent: Agent, + request: CreateGoalRequest, + signal: AbortSignal, + ): CreateGoalResult { + signal.throwIfAborted() + return this.create(agent, request) + } + + @RemoteContext('agent', 'current') + currentForClient(): CreateGoalResult { + return { accepted: true } + } + + private create(_agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return { accepted: request.objective.length > 0 } + } +} +``` + +Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. + +The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct Remotes appear under `ctx.api.`; when an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generator also projects the method without that identity parameter onto the corresponding scoped Context. `@RemoteContext` generates only the scoped invocation interface. + +```ts +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-client-remotes/client' + +declare const ctx: Context +declare const agentCtx: AgentContext +declare const agentId: SessionId + +await ctx.api.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.goals.create({ objective: 'ship it' }) +``` + +Client applications assemble only `@deepseek-ai/dsh-client-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the Host API Gateway or the business package's Remote JS separately. + +A future TUI can assemble the same React-independent `client-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. + +## Component responsibilities + +| Location | Package or entry | Responsibility | +|---|---|---| +| Shared | `@deepseek-ai/dsh-type-meta` | Declares decorators, Gateway bindings, merge-extensible protocol maps, invocation descriptors, and provider types; starts no TypeScript analysis and registers no Cordis services | +| Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | +| Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | +| Host | `@deepseek-ai/dsh-host-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Client | `@deepseek-ai/dsh-host-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-client-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | +| Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and current `/api` HTTP bridge | + +The Host API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. + +## 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. + +Each contributing business package writes generated files to its own `lib/` directory, not to its source directory: + +| File | Consumer | Contents | +|---|---|---| +| `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | +| `typert.host.d.ts` | Host type system | Generated declarations for the Host face | +| `typert.remote-client.js` | `client-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | +| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | +| `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | + +Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. + +Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.api.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. + +Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. + +## Runtime invocation + +Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client API calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. + +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The TypeRT Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier in the future does not require changes to Remote descriptors or the Client programming interface. + +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary before entering or after leaving business code. + +Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. + +## SRC development fallback + +When the Host starts from source through `node --import tsx/esm`, it does not execute the TypeRT compiler plugin. Standard decorator initializers still record the method name and invocation mode in a module-private `WeakMap`, while `GatewayService` or `bindTypeRTGateway()` supplies the explicit service binding; the Gateway can therefore construct a weaker temporary descriptor without starting a `ts.Program`. + +The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. + +SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client API refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. + +## Development mode + +A complete build generates Host contracts before compiling the Host, Client, and Web, so it is the deterministic entry for creating or refreshing all artifacts: + +```sh +pnpm run build +``` + +Web development normally starts the source Host after one complete build and runs the Client plugin watcher in another terminal: + +```sh +pnpm run dsh -- web --dev +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: + +```sh +pnpm run build:lib:contracts +``` + +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. + +## Boundaries + +Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md new file mode 100644 index 0000000000..d447cea6b6 --- /dev/null +++ b/docs/api-gateway.zh.md @@ -0,0 +1,157 @@ +# API Gateway + +[English](api-gateway.md) | 中文 + +本文是 TypeRT API Gateway 的当前状态参考。它描述业务 Service 如何声明一元 Remote 方法、构建如何生成 Host 与 Client 契约,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 + +## 编程模型 + +业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 + +`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为当前的实时对象。 + +`@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 + +Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的 Service 可以改为声明 `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 + +```ts +import type { Agent } from '@deepseek-ai/dsh-agent' +import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import type { Context } from 'cordis' + +export interface CreateGoalRequest { + objective: string +} + +export interface CreateGoalResult { + accepted: boolean +} + +export class GoalService extends GatewayService { + constructor(ctx: Context) { + super(ctx, 'goals') + } + + @Remote('create') + createForClient( + agent: Agent, + request: CreateGoalRequest, + signal: AbortSignal, + ): CreateGoalResult { + signal.throwIfAborted() + return this.create(agent, request) + } + + @RemoteContext('agent', 'current') + currentForClient(): CreateGoalResult { + return { accepted: true } + } + + private create(_agent: Agent, request: CreateGoalRequest): CreateGoalResult { + return { accepted: request.objective.length > 0 } + } +} +``` + +Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 + +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接 Remote 出现在 `ctx.api.`;当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成器还会把去掉该 identity 参数后的方法投影到对应作用域 Context。`@RemoteContext` 只生成作用域调用界面。 + +```ts +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-client-remotes/client' + +declare const ctx: Context +declare const agentCtx: AgentContext +declare const agentId: SessionId + +await ctx.api.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.goals.create({ objective: 'ship it' }) +``` + +Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 Host API Gateway 或业务包的 Remote JS。 + +未来的 TUI 可以装配同一个不依赖 React 的 `client-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 + +## 组件职责 + +| 位置 | 包或入口 | 职责 | +|---|---|---| +| 共享 | `@deepseek-ai/dsh-type-meta` | 声明 decorator、Gateway binding、可合并协议映射、调用描述符及提供方类型;不启动 TypeScript 分析,也不注册 Cordis 服务 | +| 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | +| Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | +| Host | `@deepseek-ai/dsh-host-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Client | `@deepseek-ai/dsh-host-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-client-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | +| 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | + +Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 + +## 严格生成链路 + +根构建按 `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` 声明合并冲突。 + +每个贡献业务包把生成文件写入自己的 `lib/`,而不是源码目录: + +| 文件 | 消费方 | 内容 | +|---|---|---| +| `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | +| `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | +| `typert.remote-client.js` | `client-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | +| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | +| `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | + +业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 + +Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.api.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 + +严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 + +## 运行时调用 + +当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client API 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 + +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。TypeRT Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 + +Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 + +Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 + +## SRC 开发回退 + +Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译插件。标准 decorator 初始化器仍会把方法名和调用模式记录到模块私有 `WeakMap`,`GatewayService` 或 `bindTypeRTGateway()` 则提供显式 service binding;Gateway 因而可以在不启动 `ts.Program` 的情况下构造一个较弱的临时描述符。 + +SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 + +SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client API 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 + +## 开发模式 + +完整构建会先生成 Host 契约,再编译 Host、Client 与 Web,因此是建立或刷新所有产物的确定性入口: + +```sh +pnpm run build +``` + +Web 开发通常在完成一次构建后启动源码 Host,并在另一个终端运行 Client plugin watcher: + +```sh +pnpm run dsh -- web --dev +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 使用新的产物: + +```sh +pnpm run build:lib:contracts +``` + +运行中的 Client watcher 会在重新打包时消费这些生成文件;没有 watcher 时运行 `pnpm run build:lib:client`。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 自带 `build:lib:contracts` 前置步骤,CI 与发布构建也使用严格生成链路。 + +## 边界 + +Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8daacae254..774bc296b1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 81464d9c8800556565c84d33239882dc750180a8 -architecture.zh.md: c02bca4f12c3758723b0fc818c89080dccf435d9 +architecture.md: db5991d98dfbc6b04992d62d5a465c375c9a78b8 +architecture.zh.md: 2eb8c3834a6ffc3283c8aa669be481b534bb5914 diff --git a/docs/architecture.md b/docs/architecture.md index 81464d9c88..db5991d98d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | +| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c02bca4f12..2eb8c3834a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,6 +48,7 @@ | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | +| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 95ed34cce0..2ea336b1f0 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: 30a2bd0a2c97df8d3d75ec50f47b861b3a65590e -development.zh.md: 5582a85429c97c3e31517a495c69392b80885f7d +development.md: d480f548dd24ea81d132e4b4c0cc364ce1b0cd53 +development.zh.md: 08ef7fd2d3da7db83eb3ca4dff9f9c85f6d7cb5e diff --git a/docs/development.md b/docs/development.md index 30a2bd0a2c..d480f548dd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,6 +62,8 @@ Host and client stay two aggregate programs because both sides declaration-merge 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). +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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. + If a relevant local check consumes built package output, build once first: ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index 5582a85429..08ef7fd2d3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,6 +62,8 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 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)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 + 如果相关的本地检查需要使用构建后的包产物,请先构建一次: ```sh From da1ebd2b68a14af5d8688942fcedcfef59ff32a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:47:17 +0800 Subject: [PATCH 45/88] refactor(goal): call entity methods through remote API --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 13 ++-- ...026-08-02-typert-remote-method-calls.zh.md | 13 ++-- docs/cordis-catalog/services.md | 51 ++------------ .../client/remotes/tests/built-lib.e2e.ts | 12 +++- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 4 +- packages/client/ui-goal/README.zh.md | 4 +- packages/client/ui-goal/package.json | 5 +- packages/client/ui-goal/src/client/index.ts | 51 +++++++++----- .../ui-goal/tests/browser-plugin.spec.tsx | 70 ++++++++++++------- packages/client/ui-goal/tsconfig.json | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 30 ++------ packages/goal/goal/src/index.ts | 61 ++-------------- packages/goal/goal/tests/goal.spec.ts | 12 ++-- pnpm-lock.yaml | 6 +- 16 files changed, 140 insertions(+), 204 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 3808a8d363..1a59abbb43 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: ade8eb827ae765677be8dcdb0ffec965c67bc4ab -2026-08-02-typert-remote-method-calls.zh.md: 2de887a2a0e46148fbb2b5ac52cfd7e3b2305b8d +2026-08-02-typert-remote-method-calls.md: c810a221a23549f3e17e25bd40fcc1fc0f9ec868 +2026-08-02-typert-remote-method-calls.zh.md: 38e3ca286dad98665269697f49fba731346e665e diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index ade8eb827a..c810a221a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -40,7 +40,7 @@ The Host Gateway does not depend on concrete implementations of `ctx.agents`, `c ## Business declarations -Ordinary direct calls use `@Remote`. When migrating to an existing Service or Registry, do not rename or alter existing methods. Add `remoteExport*` entry points at the end of the class and use decorator arguments to declare their short API names. A method explicitly declares every required business object in a top-level parameter position: +Ordinary direct calls use `@Remote`. When an existing method's parameters and result are already the intended Remote contract, decorate that method directly without renaming it. Add a `remoteExport*` adapter only when the wire contract needs a distinct request or result shape, and use the decorator argument to declare its short API name. A method explicitly declares every required business object in a top-level parameter position: ```text export class GoalService extends GatewayService { @@ -48,13 +48,14 @@ export class GoalService extends GatewayService { super(ctx, 'goals') } - create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + create(agent: Agent, request: CreateGoalRequest): GoalView { // Existing business method remains unchanged. } @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { - return this.create(agent, request) + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } } } ``` @@ -84,7 +85,7 @@ A method that cooperatively supports cancellation declares `signal: AbortSignal` ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names, while the actual member remains named `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. @@ -174,7 +175,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. -Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the method-name token of the Host `remoteExport*` method and emits a source-map segment on the corresponding property of the namespace interface. After the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. +Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. @@ -480,7 +481,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp ## Verification -- Goal Service keeps its existing business method and adds an explicit `typertGateway` plus `@Remote('create') remoteExportCreate(...)`, without a second route, codec, or Client method list. +- Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 2de887a2a0..38e3ca286d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -40,7 +40,7 @@ Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.http ## 业务声明 -普通直接调用使用 `@Remote`。迁移到现存 Service 或 Registry 时不重命名、不改变存量方法;类末尾新增 `remoteExport*` 出口,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: +普通直接调用使用 `@Remote`。现有方法的参数和结果已经是预期的 Remote 契约时,直接装饰该方法,不为此重命名。只有 wire 契约需要不同的请求或结果形态时,才新增 `remoteExport*` 适配器,并由 decorator 参数声明短 API 名。方法需要哪个业务对象,就在顶层参数位置显式声明该对象: ```text export class GoalService extends GatewayService { @@ -48,13 +48,14 @@ export class GoalService extends GatewayService { super(ctx, 'goals') } - create(agent: Agent, request: CreateGoalRequest): CreateGoalResult { + create(agent: Agent, request: CreateGoalRequest): GoalView { // Existing business method remains unchanged. } @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult { - return this.create(agent, request) + const view = this.create(agent, request) + return { ref: { id: view.id, revision: view.revision } } } } ``` @@ -84,7 +85,7 @@ export class ScopedGoalService extends GatewayService { ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名,实际成员名保持 `remoteExportCreate`;未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 @@ -174,7 +175,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ 因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 -Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 的 `remoteExport*` 方法名 token,并在 namespace interface 的对应属性上写入 source-map segment;TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 +Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 @@ -480,7 +481,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS ## 验证 -- Goal Service 保留既有业务方法,并新增显式 `typertGateway` 与 `@Remote('create') remoteExportCreate(...)`,无需第二条路由、第二份 codec 或 Client 方法清单。 +- Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 99ffaca7c7..8a646fc177 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -718,7 +718,7 @@ create(agent: Agent, request: CreateGoalRequest): GoalView * @param request - at least one replacement field. * @returns the edited view. */ -edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView +@Remote('edit') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView /** * Pause an active goal and disarm automatic continuation. @@ -726,7 +726,7 @@ edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView * @param ref - expected current revision. * @returns the paused view. */ -pause(agent: Agent, ref: GoalRef): GoalView +@Remote('pause') pause(agent: Agent, ref: GoalRef): GoalView /** * Resume and arm a stopped goal, or rearm an active goal after a @@ -735,7 +735,7 @@ pause(agent: Agent, ref: GoalRef): GoalView * @param ref - expected current revision. * @returns the active view. */ -resume(agent: Agent, ref: GoalRef): GoalView +@Remote('resume') resume(agent: Agent, ref: GoalRef): GoalView /** * Mark a current non-complete goal complete and disarm it. @@ -743,7 +743,7 @@ resume(agent: Agent, ref: GoalRef): GoalView * @param ref - expected current revision. * @returns the completed view. */ -complete(agent: Agent, ref: GoalRef): GoalView +@Remote('complete') complete(agent: Agent, ref: GoalRef): GoalView /** * Mark an active goal blocked and disarm it. @@ -760,7 +760,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView * @param ref - expected current revision. * @returns the tombstone ref whose revision is one past the cleared snapshot. */ -clear(agent: Agent, ref: GoalRef): GoalRef +@Remote('clear') clear(agent: Agent, ref: GoalRef): GoalRef /** * Create one Goal through the remote boundary. @@ -769,47 +769,6 @@ clear(agent: Agent, ref: GoalRef): GoalRef * @returns the created Goal identity. */ @Remote('create') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult - -/** - * Edit one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @param request - replacement fields. - * @returns the edited Goal view. - */ -@Remote('edit') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView - -/** - * Pause one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the paused Goal view. - */ -@Remote('pause') remoteExportPause(agent: Agent, ref: GoalRef): GoalView - -/** - * Resume one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the resumed Goal view. - */ -@Remote('resume') remoteExportResume(agent: Agent, ref: GoalRef): GoalView - -/** - * Complete one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the completed Goal view. - */ -@Remote('complete') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView - -/** - * Clear one terminal Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the committed clear revision. - */ -@Remote('clear') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef ``` Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalResult](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/client/remotes/tests/built-lib.e2e.ts index bef3f4ad65..0cee3eb245 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/client/remotes/tests/built-lib.e2e.ts @@ -148,11 +148,17 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { invalidRejected = true } const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) + const rootEdit = await client.api.goals.edit( + rootAgent.id, + rootResult.ref, + { objective: 'edited root goal' }, + ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, rootResult, + rootEdit, scopedResult, rootGoal: host.goals.get(rootAgent)?.objective, scopedGoal: host.goals.get(scopedAgent)?.objective, @@ -174,6 +180,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { invalidRejected: boolean rootResult: { ref: { id: string; revision: number } } + rootEdit: { objective: string; revision: number } scopedResult: { ref: { id: string; revision: number } } rootGoal: string scopedGoal: string @@ -183,10 +190,11 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { expect(output).toMatchObject({ invalidRejected: true, rootResult: { ref: { revision: 1 } }, + rootEdit: { objective: 'edited root goal', revision: 2 }, scopedResult: { ref: { revision: 1 } }, - rootGoal: 'root goal', + rootGoal: 'edited root goal', scopedGoal: 'scoped goal', - rootEvents: 1, + rootEvents: 2, scopedEvents: 1, }) expect(output.rootResult.ref.id).toMatch(/^goal-/) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 5120d720cd..f30f14ed48 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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-goal/README.md -README.md: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde -README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9 +README.md: b99aaf624a7d669879ba668938ee455e3cdc68ad +README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 0ea00b8bf9..b99aaf624a 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. ## Model Experience -Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content. +Indirectly, through the `goals/edit`, `goals/pause`, `goals/resume`, and `goals/clear` Remote methods the strip invokes: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content. #### KV Cache effect diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 70bf443118..3d823d0130 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.api.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 ## 模型体验 -间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。 +间接影响:条带通过调用 `goals/edit`、`goals/pause`、`goals/resume` 和 `goals/clear` Remote 方法提交变更;每次被接受的变更都会在持久 `agent/inbox/spliced` 插入项中提交,goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。 #### KV Cache 影响 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 9430da812f..4c26405bd8 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -25,6 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -36,8 +37,8 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-remotes": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -48,8 +49,8 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 6ee340715c..19b88139b5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -4,19 +4,19 @@ * arrives through `useProjection('goal')` (seeded by the history tail page, * updated by session/projection frames), so this plugin owns no store, no * refresh chain, and no event listener. The inject face carries only the - * three mutation verbs (edit/resume/clear over the goal.* wire domain); + * four mutation verbs through the generated Goal Remote API; * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ -import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. +import type {} from '@deepseek-ai/dsh-client-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet). -import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' +import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client' import type { GoalActionResult, GoalBarActions } from './slots.ts' import { GoalDock } from './GoalBar.tsx' import { en, zh, type GoalKey } from './locales.ts' @@ -35,13 +35,32 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'goal' -/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs, locale for the copy. */ -export const inject = ['slots', 'sessions', 'connection', 'locale'] +/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ +export const inject = ['slots', 'sessions', 'api', 'locale'] -/** Map one settled RPC result onto the strip's inline-render shape. */ -function settle(result: RpcResult): GoalActionResult { - if (result.ok) return { ok: true } - return { ok: false, error: { code: result.error.code, message: result.error.message } } +/** Map one generated Remote call onto the strip's inline-render shape. */ +async function settle(result: Promise): Promise { + try { + await result + return { ok: true } + } catch (error) { + const cause = error instanceof Error ? error.cause : undefined + if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } } + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : 'goal mutation failed', + }, + } + } +} + +function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } { + return value !== null + && typeof value === 'object' + && typeof (value as { code?: unknown }).code === 'string' + && typeof (value as { message?: unknown }).message === 'string' } /** @@ -51,7 +70,7 @@ function settle(result: RpcResult): GoalActionResult { export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') - const { goals } = (ctx.get('connection') as ConnectionHandle).api + const { goals } = ctx.api const sessions = ctx.sessions @@ -77,22 +96,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.edit({ sessionId, ref, objective })).result) + return settle(goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.pause({ sessionId, ref })).result) + return settle(goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.resume({ sessionId, ref })).result) + return settle(goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle((await goals.clear({ sessionId, ref })).result) + return settle(goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 98d5a0291a..eddb272be4 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -1,11 +1,11 @@ // @vitest-environment jsdom /** - * ui-goal browser half on a real cordis Context with fake slots/connection/ + * ui-goal browser half on a real cordis Context with fake slots/api/ * sessions faces: the plugin registers the GoalBar dock entry at - * conversation.input.dock, the inject face's three verbs read the CAS ref + * conversation.input.dock, the inject face's four verbs read the CAS ref * from the session's CURRENT projected value at call time (no fence — the - * RPC's compare-and-set is the guard), a missing projection short-circuits - * to the no-current-goal error without touching the wire, and RPC errors + * Remote method's compare-and-set is the guard), a missing projection short-circuits + * to the no-current-goal error without touching the wire, and Remote errors * map onto the inline-render result shape. Registration disposal rides the * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. @@ -44,27 +44,32 @@ function makeProjection(revision = 3): GoalProjection { } } -/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */ -async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) { +/** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */ +async function bench(options: { + projection?: GoalProjection | null | undefined + failWith?: { code: string; message: string } + rejectWith?: unknown +} = {}) { const ctx = new Context() - const calls: { method: string; payload: unknown }[] = [] + const calls: { method: string; args: unknown[] }[] = [] function answer(method: string, value: T) { - return (payload: unknown) => { - calls.push({ method, payload }) - return Promise.resolve({ - result: options.failWith === undefined - ? { ok: true as const, value } - : { ok: false as const, error: { ...options.failWith, details: {} } }, - }) + return (...args: unknown[]) => { + calls.push({ method, args }) + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test. + if ('rejectWith' in options) return Promise.reject(options.rejectWith) + if (options.failWith !== undefined) { + return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith })) + } + return Promise.resolve(value) } } const ref = { id: 'g-1', revision: 3 } - ctx.provide('connection', { api: { goals: { - edit: answer('goal.edit', { ref }), - pause: answer('goal.pause', { ref }), - resume: answer('goal.resume', { ref }), - clear: answer('goal.clear', { cleared: true as const }), - } } }) + ctx.provide('api', { goals: { + edit: answer('goals/edit', { ref }), + pause: answer('goals/pause', { ref }), + resume: answer('goals/resume', { ref }), + clear: answer('goals/clear', ref), + } }) await ctx.plugin(SlotsService).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, @@ -113,12 +118,12 @@ describe('ui-goal browser plugin', () => { expect(await verbs.onPause()).toEqual({ ok: true }) expect(await verbs.onResume()).toEqual({ ok: true }) expect(await verbs.onClear()).toEqual({ ok: true }) - expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear']) + expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear']) const ref = { id: 'g-1', revision: 5 } - expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' }) - expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref }) - expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref }) - expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref }) + expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }]) + expect(b.calls[1]?.args).toEqual(['s1', ref]) + expect(b.calls[2]?.args).toEqual(['s1', ref]) + expect(b.calls[3]?.args).toEqual(['s1', ref]) }) it('a null or absent projection short-circuits every verb without touching the wire', async () => { @@ -133,13 +138,26 @@ describe('ui-goal browser plugin', () => { } }) - it('maps a settled RPC error onto the inline-render shape', async () => { + it('maps a Remote error onto the inline-render shape', async () => { const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) await b.fiber.await() const verbs = b.entry()!.inject!(sid('s1')) expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) }) + it.each([ + [new Error('connection closed'), 'connection closed'], + ['connection closed', 'goal mutation failed'], + [new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'], + [new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'], + [new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'], + ])('maps an unstructured rejection onto an internal error', async (rejection, message) => { + const b = await bench({ projection: makeProjection(), rejectWith: rejection }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } }) + }) + it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { const b = await bench() await b.fiber.await() diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index ad863bdc32..2bb4070b18 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -12,10 +12,10 @@ "path": "../../../vendor/cordis" }, { - "path": "../connection" + "path": "../locale" }, { - "path": "../locale" + "path": "../remotes" }, { "path": "../runtime" diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d8d067ce3e..2627d43b69 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -359,19 +359,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', }, { - signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + signature: '@Remote(\'edit\') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */', }, { - signature: 'pause(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'pause\') pause(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */', }, { - signature: 'resume(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'resume\') resume(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */', }, { - signature: 'complete(agent: Agent, ref: GoalRef): GoalView', + signature: '@Remote(\'complete\') complete(agent: Agent, ref: GoalRef): GoalView', jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */', }, { @@ -379,33 +379,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */', }, { - signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', + signature: '@Remote(\'clear\') clear(agent: Agent, ref: GoalRef): GoalRef', jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', }, { signature: '@Remote(\'create\') remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult', jsDoc: '/**\n * Create one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param request - objective and optional round cap.\n * @returns the created Goal identity.\n */', }, - { - signature: '@Remote(\'edit\') remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', - jsDoc: '/**\n * Edit one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @param request - replacement fields.\n * @returns the edited Goal view.\n */', - }, - { - signature: '@Remote(\'pause\') remoteExportPause(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Pause one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the paused Goal view.\n */', - }, - { - signature: '@Remote(\'resume\') remoteExportResume(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Resume one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the resumed Goal view.\n */', - }, - { - signature: '@Remote(\'complete\') remoteExportComplete(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Complete one Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the completed Goal view.\n */', - }, - { - signature: '@Remote(\'clear\') remoteExportClear(agent: Agent, ref: GoalRef): GoalRef', - jsDoc: '/**\n * Clear one terminal Goal through the remote boundary.\n * @param agent - exact live Agent resolved from the wire identity.\n * @param ref - expected current revision.\n * @returns the committed clear revision.\n */', - }, ], }, { diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 312e3a70d9..6667463d86 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -273,6 +273,7 @@ export class GoalService extends GatewayService { * @param request - at least one replacement field. * @returns the edited view. */ + @Remote('edit') edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -294,6 +295,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the paused view. */ + @Remote('pause') pause(agent: Agent, ref: GoalRef): GoalView { return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed') } @@ -305,6 +307,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the active view. */ + @Remote('resume') resume(agent: Agent, ref: GoalRef): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -330,6 +333,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the completed view. */ + @Remote('complete') complete(agent: Agent, ref: GoalRef): GoalView { return this.transition( agent, @@ -369,6 +373,7 @@ export class GoalService extends GatewayService { * @param ref - expected current revision. * @returns the tombstone ref whose revision is one past the cleared snapshot. */ + @Remote('clear') clear(agent: Agent, ref: GoalRef): GoalRef { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) @@ -582,62 +587,6 @@ export class GoalService extends GatewayService { const view = this.create(agent, request) return { ref: { id: view.id, revision: view.revision } } } - - /** - * Edit one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @param request - replacement fields. - * @returns the edited Goal view. - */ - @Remote('edit') - remoteExportEdit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { - return this.edit(agent, ref, request) - } - - /** - * Pause one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the paused Goal view. - */ - @Remote('pause') - remoteExportPause(agent: Agent, ref: GoalRef): GoalView { - return this.pause(agent, ref) - } - - /** - * Resume one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the resumed Goal view. - */ - @Remote('resume') - remoteExportResume(agent: Agent, ref: GoalRef): GoalView { - return this.resume(agent, ref) - } - - /** - * Complete one Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the completed Goal view. - */ - @Remote('complete') - remoteExportComplete(agent: Agent, ref: GoalRef): GoalView { - return this.complete(agent, ref) - } - - /** - * Clear one terminal Goal through the remote boundary. - * @param agent - exact live Agent resolved from the wire identity. - * @param ref - expected current revision. - * @returns the committed clear revision. - */ - @Remote('clear') - remoteExportClear(agent: Agent, ref: GoalRef): GoalRef { - return this.clear(agent, ref) - } } export default GoalService diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 2dd5885cc7..3c642d2a8c 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -245,14 +245,14 @@ describe('GoalService creation and replay', () => { }) describe('GoalService mutations', () => { - it('exposes the supported mutation sequence through Remote wrappers', async () => { + it('adapts Remote creation and reuses business methods for later mutations', async () => { const { ctx, agent } = await harness() const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' }) - const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' }) - const paused = ctx.goals.remoteExportPause(agent, edited) - const resumed = ctx.goals.remoteExportResume(agent, paused) - const completed = ctx.goals.remoteExportComplete(agent, resumed) - const cleared = ctx.goals.remoteExportClear(agent, completed) + const edited = ctx.goals.edit(agent, created.ref, { objective: 'edited remotely' }) + const paused = ctx.goals.pause(agent, edited) + const resumed = ctx.goals.resume(agent, paused) + const completed = ctx.goals.complete(agent, resumed) + const cleared = ctx.goals.clear(agent, completed) expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 }) expect(paused).toMatchObject({ phase: 'paused', revision: 3 }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a4453a61..79d38a43cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1617,12 +1617,12 @@ importers: packages/client/ui-goal: devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-remotes': + specifier: workspace:^ + version: link:../remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 2e1f9a5ceaf1c801f018791c326ea29fe42caf3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:55 +0800 Subject: [PATCH 46/88] fix(typert): address remote gateway review --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/host/api-gateway/src/client/index.ts | 51 +++++++--- .../host/api-gateway/tests/client.spec.ts | 18 ++++ packages/typert/generator/package.json | 1 + packages/typert/generator/src/analyzer.ts | 39 ++++---- packages/typert/generator/src/emitter.ts | 9 +- .../typert/generator/src/tsdown-plugin.ts | 11 ++- packages/typert/generator/src/workspace.ts | 20 ++-- .../fixtures/remote-model/type-meta.d.ts | 8 +- .../generator/tests/remote-model.spec.ts | 97 ++++++++++++++++++- .../generator/tests/tsdown-plugin.spec.ts | 33 ++++++- packages/typert/generator/tsconfig.json | 3 + packages/typert/registry/src/service.ts | 6 +- packages/typert/registry/tests/typert.spec.ts | 8 ++ packages/typert/type-meta/src/index.ts | 15 ++- .../typert/type-meta/tests/type-meta.spec.ts | 3 + pnpm-lock.yaml | 3 + 19 files changed, 275 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 1a59abbb43..2400e39519 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c810a221a23549f3e17e25bd40fcc1fc0f9ec868 -2026-08-02-typert-remote-method-calls.zh.md: 38e3ca286dad98665269697f49fba731346e665e +2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48 +2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index c810a221a2..13b407d580 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -345,7 +345,7 @@ SRC supports local source startup. The `WeakMap` records created by `@Remote` an For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. -A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. +A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 38e3ca286d..434cf4765d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -345,7 +345,7 @@ SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记 例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 -SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。 +SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。 LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。 diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 292df54152..3fc8389079 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -134,8 +134,11 @@ class ClientApiService extends Service implements ClientApi { const record = this.scoped.get(namespace) if (record !== undefined) { for (const method of methods) record.service.assertMethodAvailable(method) - } else if (this.ownerCtx.reflect.props[namespace] !== undefined) { - throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } else { + for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) + if (this.ownerCtx.reflect.props[namespace] !== undefined) { + throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + } } } } @@ -143,11 +146,18 @@ class ClientApiService extends Service implements ClientApi { private install(descriptor: InvocationDescriptor): () => void { const token: MountToken = { active: true, abort: new AbortController() } const installed: (() => void)[] = [] - if (descriptor.invocation.kind === 'direct') { - installed.push(this.installDirect(descriptor, token)) + try { + if (descriptor.invocation.kind === 'direct') { + installed.push(this.installDirect(descriptor, token)) + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + } catch (error) { + token.active = false + for (const dispose of installed.reverse()) dispose() + token.abort.abort() + throw error } - const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) return () => { /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return @@ -192,19 +202,19 @@ class ClientApiService extends Service implements ClientApi { ): () => void { let namespace = this.scoped.get(descriptor.namespace) if (namespace === undefined) { - namespace = { - service: new ScopedRemoteNamespace( - this.ownerCtx, - descriptor.namespace, - (current, currentProjection, currentToken, caller, args) => - this.invoke(current, currentProjection, currentToken, caller, args), - ), - tokens: new Map(), - } + const service = new ScopedRemoteNamespace( + this.ownerCtx, + descriptor.namespace, + (current, currentProjection, currentToken, caller, args) => + this.invoke(current, currentProjection, currentToken, caller, args), + ) + service.install(descriptor, projection, token) + namespace = { service, tokens: new Map() } this.scoped.set(descriptor.namespace, namespace) + } else { + namespace.service.install(descriptor, projection, token) } namespace.tokens.set(descriptor.method, token) - namespace.service.install(descriptor, projection, token) return () => { /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return @@ -275,6 +285,12 @@ class ScopedRemoteNamespace extends Service { private readonly ownerCtx: Context private readonly methods = new Set() + static assertMethodAvailable(namespace: string, method: string): void { + if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { + throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) + } + } + constructor( ctx: Context, name: string, @@ -285,6 +301,7 @@ class ScopedRemoteNamespace extends Service { } assertMethodAvailable(method: string): void { + ScopedRemoteNamespace.assertMethodAvailable(this.name, method) if (method in this) { throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) } @@ -311,6 +328,8 @@ class ScopedRemoteNamespace extends Service { } } +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx']) + function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 3ad00ff0fc..28aa848fcd 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -279,6 +279,24 @@ describe('Client TypeRT API', () => { await disposeMultipleScoped() }) + it('rolls back direct projection when scoped installation fails', async () => { + const ctx = await bench(vi.fn()) + const descriptor: InvocationDescriptor = { + ...directDescriptor(), + id: '@fixture/goals#fresh/remove', + namespace: 'fresh', + method: 'remove', + } + + for (const packageName of ['@fixture/first-attempt', '@fixture/second-attempt']) { + expect(() => ctx.api.mount({ package: packageName, descriptors: [descriptor] })) + .toThrow('conflicts with its namespace service') + expect((ctx.api as unknown as Record).fresh).toBeUndefined() + expect(ctx.get('fresh')).toBeUndefined() + expect(ctx.typert.remotes.list()).toEqual([]) + } + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 5ffb933214..eed553ebed 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,6 +30,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^", "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index ecc7d8aa6b..5e26245171 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,6 +8,7 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' +import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { CrossFaceLink, DocumentationModel, @@ -1171,8 +1172,8 @@ class FaceAnalyzer { namespace = value } } - if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"') - if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"') + if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must contain only RPC endpoint segment characters') + if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must contain only RPC endpoint segment characters') return { service, namespace, site } } @@ -1196,7 +1197,7 @@ class FaceAnalyzer { if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') const exportName = stringLiteralValue(expression.arguments[0]) if (exportName === undefined || !isRemoteSegment(exportName)) { - this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"') + this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) @@ -1206,12 +1207,12 @@ class FaceAnalyzer { } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { - this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"') + this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a string literal containing only RPC endpoint segment characters') } const exportArgument = expression.arguments[1] const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { - this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"') + this.fail(exportArgument, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { @@ -1251,7 +1252,7 @@ class FaceAnalyzer { this.fail(declaration, 'TypeRTLookupMap entries must be required properties') } const key = memberName(declaration.name) - if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"') + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must contain only RPC endpoint segment characters') if (!ts.isTypeReferenceNode(declaration.type) || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup') || declaration.type.typeArguments?.length !== 2) { @@ -1287,7 +1288,7 @@ class FaceAnalyzer { this.fail(declaration, 'TypeRTContextMap entries must be required properties') } const key = memberName(declaration.name) - if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"') + if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must contain only RPC endpoint segment characters') if (!ts.isTypeReferenceNode(declaration.type) || !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext') || declaration.type.typeArguments?.length !== 1) { @@ -1331,16 +1332,6 @@ class FaceAnalyzer { const type = this.convertType(authoredType) const codecType = this.resolvedRemoteCodecType(authoredType) const rootSymbol = this.namedWorkspaceType(authoredType) - if (rootSymbol !== undefined) { - const imported = this.publicRemoteType(rootSymbol, authoredType) - return { - type, - codecType, - typeSymbol: `${imported.specifier}#${imported.name}`, - imports: [imported], - } - } - if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') const imports = new Map() const visit = (node: ts.Node): void => { if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) { @@ -1355,13 +1346,23 @@ class FaceAnalyzer { && this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) { const imported = this.publicRemoteType(resolved, node) imports.set(imported.symbol, imported) - return } } } ts.forEachChild(node, visit) } visit(authoredType) + if (rootSymbol !== undefined) { + const imported = this.publicRemoteType(rootSymbol, authoredType) + return { + type, + codecType, + typeSymbol: `${imported.specifier}#${imported.name}`, + imports: [...imports.values()].sort((left, right) => + left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)), + } + } + if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types') return { type, codecType, @@ -2810,7 +2811,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return value.length > 0 && !value.includes('/') + return isTypeRTRemoteSegment(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index c8b9ab4195..bb39959606 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -414,8 +414,9 @@ export class FaceModelEmitter { invocation: InvocationModel, referenceNames: ReadonlyMap, ): void { - const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` - this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length) + const key = renderRemotePropertyName(invocation.method) + const signature = `${key}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, key.length) } private pushMappedRemoteSignature( @@ -914,6 +915,10 @@ function safeIdentifier(name: string): string { return `_${normalized}` } +function renderRemotePropertyName(name: string): string { + return /^[$A-Z_a-z][$\w]*$/u.test(name) ? name : quote(name) +} + function quote(value: string): string { return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'` } diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index 10cba60974..eca5ad47d2 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-typert-generator/tsdown */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import ts from 'typescript' import { WorkspaceTypertGenerator } from './workspace.ts' @@ -103,15 +103,24 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { const output = join(packageDir, 'lib') mkdirSync(output, { recursive: true }) + let emittedRemote = false for (const artifact of artifacts) { writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) if (artifact.remote !== undefined) { + emittedRemote = true writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) } } + if (!emittedRemote && artifacts.some(artifact => artifact.face === 'host')) { + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) rmSync(join(output, file), { force: true }) + } } function readManifest(packageDir: string): { name?: string; exports?: unknown } { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index c79861a796..6327872166 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -90,7 +90,6 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } - if (artifact.remote === undefined) return const remoteExpected = { types: './lib/typert.remote-client.d.ts', default: './lib/typert.remote-client.js', @@ -98,16 +97,25 @@ export class WorkspaceTypertGenerator { const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object' ? (manifest.exports as Record)['./remote'] : undefined + const remoteFiles = [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + ] + if (artifact.remote === undefined) { + if (remoteActual !== undefined || remoteFiles.some(file => files.includes(file))) { + throw new TypertAnalysisError( + `typert(host): ${artifact.package} publishes Remote artifacts but has no Remote methods`, + ) + } + return + } if (!sameExport(remoteActual, remoteExpected)) { throw new TypertAnalysisError( `typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`, ) } - for (const file of [ - 'lib/typert.remote-client.js', - 'lib/typert.remote-client.d.ts', - 'lib/typert.remote-client.d.ts.map', - ]) { + for (const file of remoteFiles) { if (!files.includes(file)) { throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`) } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 91daea98c2..5347a6b77e 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -50,7 +50,13 @@ declare module '@deepseek-ai/dsh-type-meta' { context: ClassMethodDecoratorContext Result>, ): void - export function RemoteContext(key: Extract): + export function Remote(exportName: string): + ( + method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ) => void + + export function RemoteContext(key: Extract, exportName?: string): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 268645ca73..4f4f3ea7cb 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -216,6 +216,91 @@ export type GenericResult = { expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false) }) + it('imports public type arguments nested under a named generic boundary', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** Generic Remote envelope. */ +export interface Box { + readonly value: Value +} + +/** Payload reachable only as a generic argument. */ +export interface BoxPayload { + readonly count: number +} +`) + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' RenameGoalResult,\n', + ' RenameGoalResult,\n Box,\n BoxPayload,\n', + ) + .replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote + box(request: Box): Box { + return request + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/) + expect(artifact?.remote?.dts).toContain('box: (request: Box) => Promise>') + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + it('quotes aliased methods in generated namespace interfaces', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', + ` rename(request: RenameGoalRequest): RenameGoalResult { + return { renamed: request.title.length > 0 } + } + + @Remote('create-goal') + createAlias(request: CreateGoalRequest): CreateGoalResult { + return { ref: request.title } + } +}`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise") + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) + }) + + it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + ' @Remote\n async create(', + ` @Remote('${alias}')\n async create(`, + )) + + expect(() => analyzeRemote(root, false)).toThrow(/RPC endpoint segment characters/) + }) + + it('rejects a Remote export after its last Remote method is removed', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace(' @Remote\n', '') + .replace(" @RemoteContext('agent')\n", '')) + editFile(root, 'packages/remote/src/types.ts', source => `${source} + +/** @typert schema */ +export interface RemainingSchema { + readonly value: string +} +`) + + expect(() => new WorkspaceTypertGenerator(root).generate()) + .toThrow('publishes Remote artifacts but has no Remote methods') + }) + it.each([ { name: 'missing binding', @@ -429,9 +514,9 @@ function remotePackage(root: string): { return packageModel } -function copyFixture(): string { +function copyFixture(sourceRoot = fixtureRoot): string { const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-')) - cpSync(fixtureRoot, root, { recursive: true }) + cpSync(sourceRoot, root, { recursive: true }) temporaryRoots.push(root) return root } @@ -444,10 +529,14 @@ function editFile(root: string, relativePath: string, edit: (source: string) => writeFileSync(path, result) } -function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void { +function assertRemoteConsumerTypechecks( + dts: string | undefined, + dtsMap: string | undefined, + sourceRoot = fixtureRoot, +): void { if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') - const consumerRoot = copyFixture() + const consumerRoot = copyFixture(sourceRoot) const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts') const declarationMapPath = `${declarationPath}.map` const consumerPath = join(consumerRoot, 'consumer.ts') diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 106b8950ff..556e96045d 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -3,8 +3,9 @@ import { mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceEmitResult } from '../src/workspace.ts' -const generated = vi.hoisted(() => vi.fn(() => [ +const generated = vi.hoisted(() => vi.fn<() => WorkspaceEmitResult[]>(() => [ { package: '@deepseek-ai/dsh-tools', packageRoot: 'packages/core/tools', @@ -141,6 +142,36 @@ describe('typertPlugin', () => { .toBe('{"version":3}\n') }) + it('removes stale Remote artifacts from a Host package without Remote output', async () => { + const root = await workspace() + const output = await packageOutput(root, 'tools', { + name: '@deepseek-ai/dsh-tools', + exports: { './typert': './lib/typert.host.js' }, + }) + const packageLib = join(root, 'packages', 'tools', 'lib') + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) writeFileSync(join(packageLib, file), 'stale\n') + generated.mockReturnValueOnce([{ + package: '@deepseek-ai/dsh-tools', + packageRoot: 'packages/core/tools', + face: 'host', + exports: [], + js: 'export const host = true\n', + dts: 'export declare const host: true\n', + }]) + + typertPlugin().writeBundle({ dir: output }) + + for (const file of [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', + ]) expect(existsSync(join(packageLib, file))).toBe(false) + }) + it('emits every explicit workspace contributor once from a host-only prepass', async () => { const root = await workspace() const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' }) diff --git a/packages/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json index 9966c8ca8a..311dfa4b6d 100644 --- a/packages/typert/generator/tsconfig.json +++ b/packages/typert/generator/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../type-meta" } ] } diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index d04f38cde5..7a097b8635 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -7,6 +7,7 @@ import { Context, Service } from 'cordis' import { z } from 'zod' +import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { InvocationDescriptor, TypeRTClientContextBinder, @@ -600,8 +601,9 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - validateSegment(subject, value) - if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`) + if (!isTypeRTRemoteSegment(value)) { + throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) + } } function validateSegment(subject: string, value: string): void { diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 5603ce8954..6661cbeeb4 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -247,6 +247,14 @@ describe('TypertRegistry', () => { })).toThrow('endpoint "goals/create" is already registered') }) + it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => { + const ctx = await makeCtx() + expect(() => ctx.typert.remotes.register({ + package: '@fixture/invalid-endpoint', + descriptors: [{ ...invocation(), method }], + })).toThrow('RPC endpoint segment characters') + }) + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { const ctx = await makeCtx() const descriptor = invocation() diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 4d4457b5be..67a4169f96 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -7,6 +7,17 @@ import { Service, type Context } from 'cordis' import type { TypeRTContextMap } from './types.ts' +const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ + +/** + * Test one generated Remote name against the Connection endpoint grammar. + * @param value - namespace, method, lookup, or Context segment. + * @returns whether the value can cross the shared RPC carrier unchanged. + */ +export function isTypeRTRemoteSegment(value: string): boolean { + return TYPERT_REMOTE_SEGMENT_PATTERN.test(value) +} + export type { InvocationDescriptor, InvocationParameterDescriptor, @@ -236,7 +247,7 @@ function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMar } function validateName(subject: string, value: string): void { - if (value.length === 0 || value.includes('/')) { - throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`) + if (!isTypeRTRemoteSegment(value)) { + throw new TypeError(`type-meta: ${subject} must contain only RPC endpoint segment characters`) } } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 8a2a4372ce..757488024d 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -162,6 +162,8 @@ describe('type-meta Remote declarations', () => { const method: (this: object) => void = function (this: object): void {} expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing') expect(() => Remote('bad/name')).toThrow('export name') + expect(() => Remote('bad#name')).toThrow('export name') + expect(() => Remote('bad name')).toThrow('export name') expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') @@ -203,6 +205,7 @@ describe('type-meta Remote declarations', () => { it('rejects ambiguous binding names', () => { expect(() => bindTypeRTGateway({}, '')).toThrow('service key') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') + expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79d38a43cd..d6889328eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6172,6 +6172,9 @@ importers: packages/typert/generator: dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../type-meta '@jridgewell/gen-mapping': specifier: ^0.3.13 version: 0.3.13 From 0ad58850155b9eab5fd1f6c000b09f28f1bb0f03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:13:19 +0800 Subject: [PATCH 47/88] fix(typert): keep generator bootstrap self-contained --- packages/typert/generator/package.json | 1 - packages/typert/generator/src/analyzer.ts | 3 +-- packages/typert/generator/tsconfig.json | 3 --- pnpm-lock.yaml | 3 --- 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index eed553ebed..5ffb933214 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -30,7 +30,6 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-type-meta": "workspace:^", "@jridgewell/gen-mapping": "^0.3.13", "typescript": "^6.0.3" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 5e26245171..16c30e8bc5 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,7 +8,6 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' -import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { CrossFaceLink, DocumentationModel, @@ -2811,7 +2810,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return isTypeRTRemoteSegment(value) + return /^[A-Za-z0-9_$.-]+$/.test(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/tsconfig.json b/packages/typert/generator/tsconfig.json index 311dfa4b6d..9966c8ca8a 100644 --- a/packages/typert/generator/tsconfig.json +++ b/packages/typert/generator/tsconfig.json @@ -16,9 +16,6 @@ }, { "path": "../../support/invariants" - }, - { - "path": "../type-meta" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6889328eb..79d38a43cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6172,9 +6172,6 @@ importers: packages/typert/generator: dependencies: - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../type-meta '@jridgewell/gen-mapping': specifier: ^0.3.13 version: 0.3.13 From 5ea631194910153c5f89f3eb7a100c012ff72288 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:22:52 +0800 Subject: [PATCH 48/88] test(api-gateway): cover client mount rollback --- .../host/api-gateway/tests/client.spec.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 28aa848fcd..5c2c427605 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -258,6 +258,13 @@ describe('Client TypeRT API', () => { package: '@fixture/service-method-conflict', descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], })).toThrow('conflicts with its namespace service') + const scopedService = ctx.get('goals') as unknown as object + Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) + expect(() => ctx.api.mount({ + package: '@fixture/service-own-property-conflict', + descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], + })).toThrow('conflicts with its namespace service') + Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() expect(() => ctx.api.mount({ @@ -281,20 +288,37 @@ describe('Client TypeRT API', () => { it('rolls back direct projection when scoped installation fails', async () => { const ctx = await bench(vi.fn()) - const descriptor: InvocationDescriptor = { - ...directDescriptor(), - id: '@fixture/goals#fresh/remove', - namespace: 'fresh', - method: 'remove', + const disposeScoped = ctx.api.mount({ + package: '@fixture/scoped-base', + descriptors: [contextDescriptor()], + }) + const defineProperty = Object.defineProperty + let createDefinitions = 0 + const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + // The direct projection defines `create` first; fail the following scoped projection. + if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure') + return defineProperty(target, key, attributes) + }) + + try { + expect(() => ctx.api.mount({ + package: '@fixture/failing-install', + descriptors: [directDescriptor()], + })).toThrow('simulated scoped installation failure') + } finally { + definePropertySpy.mockRestore() } - for (const packageName of ['@fixture/first-attempt', '@fixture/second-attempt']) { - expect(() => ctx.api.mount({ package: packageName, descriptors: [descriptor] })) - .toThrow('conflicts with its namespace service') - expect((ctx.api as unknown as Record).fresh).toBeUndefined() - expect(ctx.get('fresh')).toBeUndefined() - expect(ctx.typert.remotes.list()).toEqual([]) - } + expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect(ctx.get('goals') !== undefined).toBe(true) + expect(ctx.typert.remotes.list()).toHaveLength(1) + + const disposeRetry = ctx.api.mount({ + package: '@fixture/retry', + descriptors: [directDescriptor()], + }) + await disposeRetry() + await disposeScoped() }) it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { From 56af59b41d8a8459d36a8d3e18aa6b455cbf03be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:52:24 +0800 Subject: [PATCH 49/88] fix(typert): keep client registry bundle pure --- packages/typert/registry/src/service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 7a097b8635..2f85138edd 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -7,7 +7,6 @@ import { Context, Service } from 'cordis' import { z } from 'zod' -import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta' import type { InvocationDescriptor, TypeRTClientContextBinder, @@ -601,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - if (!isTypeRTRemoteSegment(value)) { + if (!/^[A-Za-z0-9_$.-]+$/.test(value)) { throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) } } From eca3090dfaecfd67ff79e4679f1cf90fa295eccb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:11:32 +0800 Subject: [PATCH 50/88] fix: docs --- docs/config-catalog.md | 2 -- docs/module-graph.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 41c9c98515..77baad512d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2565,8 +2565,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) -- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) -- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index f659e61206..ac46e968b3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1132,8 +1132,8 @@ flowchart TD 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_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_remotes pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -1383,7 +1383,7 @@ flowchart TD | [`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-goal`](../packages/client/ui-goal) | `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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-locale`](../packages/client/locale), [`client-remotes`](../packages/client/remotes), [`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-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) | From e28ac506edf9ea5c5c72bdba20fea1e16f708728 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:21:41 +0800 Subject: [PATCH 51/88] perf(api-gateway): cache SRC endpoint claims --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 4 +- ...026-08-02-typert-remote-method-calls.zh.md | 4 +- docs/event-producer-consumer.md | 1 + packages/host/api-gateway/src/index.ts | 21 ++++++-- .../host/api-gateway/tests/gateway.spec.ts | 49 +++++++++++++++++++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 2400e39519..02e6c428ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48 -2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313 +2026-08-02-typert-remote-method-calls.md: ddc93b4fc672f320b4e3dc3e11586d92604e6aa4 +2026-08-02-typert-remote-method-calls.zh.md: 808c7d54bff19d9a4e9cf924769df1d405d997b5 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 13b407d580..ddc93b4fc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -158,7 +158,7 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway resolves descriptors, Services, and providers from current state for every claim and invocation instead of retaining endpoint registrations. Removing a strict definition, Service, or provider therefore makes the corresponding call unavailable without leaving a stale live object. +Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway caches only the set of SRC-owned endpoint names and discards it whenever the Cordis Service set changes; it retains no descriptor, Service, or provider. Invocation resolves all live objects from current state, so removing a strict definition, Service, or provider makes the corresponding call unavailable without leaving a stale live object. The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. @@ -355,7 +355,7 @@ CI and releases use LIB. Moving all repository coverage to LIB is separate follo ## Host Gateway resolution -The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher resolves each endpoint from the current TypeRT local registry or scans current Cordis Services for a matching `typertGateway` binding and SRC Remote marker. TypeRT definitions and business Services may therefore arrive in either order. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current TypeRT local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so TypeRT definitions and business Services may arrive in either order without making legacy `/api` traffic rescan every Service on each request or letting arbitrary request paths grow the cache. Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypeRTLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 434cf4765d..808c7d54bf 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -158,7 +158,7 @@ ctx.typert.lookups wire ID 到 Host 活对象的 provider ctx.typert.contexts Host Context resolver 与 Client Context binder ``` -每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 每次认领和调用时都从当前状态解析 descriptor、Service 与提供方,不保留 endpoint 注册。因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 +每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 @@ -355,7 +355,7 @@ CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工 ## Host Gateway 解析 -Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会从当前 TypeRT local 注册表解析各 endpoint,或扫描当前 Cordis Service,查找匹配的 `typertGateway` binding 与 SRC Remote 标记。因此 TypeRT definition 与业务 Service 可以按任意顺序到达。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 TypeRT local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 TypeRT definition 与业务 Service 可以按任意顺序到达,同时既不会让旧 API Proxy 的 `/api` 流量在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypeRTLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f5b3a0b99a..34f4d37ffd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,6 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | +| `internal/service` | - | `api-gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index c4a61cef8d..7dd2410873 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -76,12 +76,17 @@ export class TypertGatewayError extends Error { export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + private srcClaims: ReadonlySet | undefined + /** * Register the Gateway against the active TypeRT registry. * @param ctx - owning Host Context with TypeRT registry access. */ constructor(ctx: Context) { super(ctx, 'typertGateway') + ctx.on('internal/service', () => { + this.srcClaims = undefined + }) ctx.inject(['connection'], (connectionCtx) => { connectionCtx.connection.rpc.intercept( '/api', @@ -95,18 +100,26 @@ export class TypertGatewayService extends Service implements TypertGateway { private claimsEndpoint(endpoint: string): boolean { const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false - const [namespace, method] = segments as [string, string] if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true + this.srcClaims ??= this.collectSrcClaims() + return this.srcClaims.has(endpoint) + } + + private collectSrcClaims(): ReadonlySet { + const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { if (definition.type !== 'service') continue const receiver = this.ctx.get(serviceKey) as unknown if (!isObject(receiver)) continue const original = originalOf(receiver) const binding = Reflect.get(original, 'typertGateway') as unknown - if (!isObject(binding) || Reflect.get(binding, 'namespace') !== namespace) continue - if (remoteMethods(original).some(candidate => (candidate.exportName ?? candidate.method) === method)) return true + if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue + const namespace = Reflect.get(binding, 'namespace') as string + for (const candidate of remoteMethods(original)) { + claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method)) + } } - return false + return claims } /** diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 6558a7ca47..aebe23da57 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -314,6 +314,25 @@ class NoBindingService extends Service { } } +class ObservedClaimService extends Service { + private readonly binding = bindTypeRTGateway(this, 'observedClaim', { namespace: 'observed-claim' }) + bindingReads = 0 + + constructor(ctx: Context) { + super(ctx, 'observedClaim') + } + + get typertGateway() { + this.bindingReads += 1 + return this.binding + } + + @Remote + run(value: string): string { + return value + } +} + class MissingMethodService extends Service { readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' }) @@ -949,6 +968,36 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('caches SRC ownership until the Cordis Service set changes', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + const observedFiber = ctx.plugin(ObservedClaimService) + await observedFiber + const connection = rawConnection(ctx) + const observed = ctx.get('observedClaim') as unknown as ObservedClaimService & { + [symbols.original]?: ObservedClaimService + } + const service = observed[symbols.original] ?? observed + + expect(connection.matches?.('legacy/list')).toBe(false) + expect(connection.matches?.('legacy/list')).toBe(false) + expect(service.bindingReads).toBe(1) + expect(connection.matches?.('observed-claim/run')).toBe(true) + expect(connection.matches?.('observed-claim/run')).toBe(true) + expect(service.bindingReads).toBe(1) + + const unrelatedFiber = ctx.plugin(NoBindingService) + await unrelatedFiber + expect(connection.matches?.('legacy/list')).toBe(false) + expect(service.bindingReads).toBe(2) + + await observedFiber.dispose() + expect(connection.matches?.('observed-claim/run')).toBe(false) + await unrelatedFiber.dispose() + }) + it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] From 286a8b168e1e4e536c75800d0b9d2523213f7991 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:53:16 +0800 Subject: [PATCH 52/88] fix(connection): route fixture calls through remote semantics --- .../client/connection/src/client/fixture.ts | 266 +++++++++++++----- .../client/connection/src/client/index.ts | 7 +- packages/client/connection/src/client/rpc.ts | 12 - .../connection/tests/client-apply.spec.ts | 32 ++- 4 files changed, 234 insertions(+), 83 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e13c0a19f6..1a9841aece 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -36,6 +36,7 @@ import type { import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' import { randomUuid } from './random-uuid.ts' +import type { ClientConnectionRpc } from '../rpc.ts' /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

(payload: P): RpcRequest

{ @@ -1329,6 +1330,16 @@ class FxInbox implements StreamConn { * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. */ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { + return createFixtureWorld(options).api +} + +interface FixtureWorld { + readonly api: ApiProxy + readonly rpc: ClientConnectionRpc +} + +/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ +function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. const sessions: SessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, @@ -1507,31 +1518,136 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return backscanGoal(log) as FxGoalProjection } - /** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */ - const fxMutateGoal = ( - request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>, - ref: { id: string; revision: number }, + type FxGoalRef = { id: string; revision: number } + type FxGoalView = FxGoalProjection['goal'] & { + roundsStarted: number + createdAt: number + updatedAt: number + activation: 'armed' | 'disarmed' + } + + const goalFailure = (message: string): RpcResult => ({ + ok: false, + error: { code: 'internal', message, details: {} }, + }) + + const requireGoalSession = (id: SessionId): RpcResult | undefined => ( + summaryOf(id) === undefined + ? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } } + : undefined + ) + + const goalView = (projection: FxGoalProjection): FxGoalView => ({ + ...projection.goal, + roundsStarted: projection.roundsStarted, + createdAt: projection.createdAt, + updatedAt: projection.updatedAt, + activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed', + }) + + /** Canonical fixture implementation of the generated Goal Remote contract. */ + const goalRemotes = { + create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const current = backscanGoal(logOf(id)) + if (current !== null && current.goal.phase !== 'complete') { + return goalFailure(`goal "${current.goal.id}" already exists`) + } + const now = Date.now() + const projection = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { + id: `fx-goal-${logOf(id).length}`, + revision: 1, + objective: request.objective, + phase: 'active', + maxGoalRounds: request.maxGoalRounds ?? 256, + }, + roundsStarted: 0, createdAt: now, updatedAt: now, + }) + return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } } + }, + edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult { + return mutateGoal(id, ref, current => ({ + ...current.goal, + revision: current.goal.revision + 1, + ...request.objective === undefined ? {} : { objective: request.objective }, + ...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds }, + })) + }, + pause(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } + : undefined + )) + }, + resume(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } + : undefined + )) + }, + complete(id: SessionId, ref: FxGoalRef): RpcResult { + return mutateGoal(id, ref, current => ( + current.goal.phase === 'complete' + ? undefined + : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } + )) + }, + clear(id: SessionId, ref: FxGoalRef): RpcResult { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const current = backscanGoal(logOf(id)) + if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { + return goalFailure('stale or missing goal revision') + } + const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 } + appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(), + }) + return { ok: true, value: tombstone } + }, + } + + /** Shared CAS mutation path behind the canonical Remote verbs. */ + function mutateGoal( + id: SessionId, + ref: FxGoalRef, next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): Promise> => { - const missing = requireSession(request) + ): RpcResult { + const missing = requireGoalSession(id) if (missing !== undefined) return missing - const id = request.payload.sessionId const current = backscanGoal(logOf(id)) if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { - return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) + return goalFailure('stale or missing goal revision') } const goal = next(current) if (goal === undefined) { - return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } }) + return goalFailure(`invalid goal transition from "${current.goal.phase}"`) } const projection = appendGoalChange(id, { kind: 'goal/change', version: 1, operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete', goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(), }) - return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) + return { ok: true, value: goalView(projection) } } + const mapGoalResult = (result: RpcResult, map: (value: T) => U): RpcResult => ( + result.ok ? { ok: true, value: map(result.value) } : result + ) + + const goalRefResult = (result: RpcResult): RpcResult<{ ref: { id: never; revision: number } }> => ( + mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } })) + ) + + const legacyGoalResponse = (request: RpcRequest

, result: RpcResult): Promise> => ( + Promise.resolve({ rpcId: request.rpcId, result }) + ) + /** At most one in-flight replay per session; cancel clears it. */ const replays = new Map; finish(aborted: boolean): void }>() @@ -1777,7 +1893,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { replays.set(id, { timer: setTimeout(tick, 80), finish }) } - return { + const api: ApiProxy = { sessions: { list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), search: (request, signal) => { @@ -2334,60 +2450,44 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, goals: { - // Mutation-only mirror of the host handlers: each verb CAS-checks the - // projected current goal, appends the whole-value change (the mux - // stream and projection frame ride the shared append path), and - // acknowledges with the new ref only. - create: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - const current = backscanGoal(logOf(id)) - if (current !== null && current.goal.phase !== 'complete') { - return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } }) - } - const projection = appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'create', - goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 }, - roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), - }) - return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) - }, - edit: request => fxMutateGoal(request, request.payload.ref, current => ({ - ...current.goal, - revision: current.goal.revision + 1, - ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, - ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, - })), - pause: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } - : undefined - )), - resume: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' - ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } - : undefined - )), - complete: request => fxMutateGoal(request, request.payload.ref, current => ( - current.goal.phase === 'complete' - ? undefined - : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } - )), - clear: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const id = request.payload.sessionId - const current = backscanGoal(logOf(id)) - if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) { - return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) - } - appendGoalChange(id, { - kind: 'goal/change', version: 1, operation: 'clear', - cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(), - }) - return ok(request, { cleared: true as const }) - }, + // Compatibility face only: old API Proxy payloads and acknowledgements + // adapt to the canonical fixture Remote implementation above. + create: request => legacyGoalResponse( + request, + mapGoalResult( + goalRemotes.create(request.payload.sessionId, { + objective: request.payload.objective, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + }), + value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }), + ), + ), + edit: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, { + ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + })), + ), + pause: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)), + ), + resume: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)), + ), + complete: request => legacyGoalResponse( + request, + goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)), + ), + clear: request => legacyGoalResponse( + request, + mapGoalResult( + goalRemotes.clear(request.payload.sessionId, request.payload.ref), + () => ({ cleared: true as const }), + ), + ), }, events: { async *mux(_request, signal) { @@ -2548,6 +2648,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return Promise.resolve({ accepted: true }) }, } + + const rpc: ClientConnectionRpc = { + call(channel, endpoint, payload) { + if (channel !== '/api') { + return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`)) + } + const args = (payload as { + args: { + agentId: SessionId + ref?: { id: string; revision: number } + request?: { objective?: string; maxGoalRounds?: number } + } + }).args + const sessionId = args.agentId + switch (endpoint) { + case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { + objective: args.request?.objective as string, + ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, + })) + case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {})) + case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef)) + case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) + case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) + case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) + default: + return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`)) + } + }, + } + return { api, rpc } } /** @@ -2559,10 +2689,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { */ export class FixtureApiClient extends AbstractApiClient { private readonly api: ApiProxy + /** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */ + readonly rpc: ClientConnectionRpc constructor() { super() - this.api = createFixtureApi(fixtureOptionsFromLocation()) + const world = createFixtureWorld(fixtureOptionsFromLocation()) + this.api = world.api + this.rpc = world.rpc } protected doFetch(): Promise { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 521e54160e..c2a6668d46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,7 +8,7 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts' +import { createWebConnectionRpc } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -74,8 +74,9 @@ export interface ConnectionHandle { export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') - const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() - const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc() + const fixtureClient = fixture ? new FixtureApiClient() : undefined + const api: IApiClient = fixtureClient ?? new WebApiClient() + const rpc = fixtureClient?.rpc ?? createWebConnectionRpc() let started = false const handle: ConnectionHandle = { api, diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 7883f2a9d3..f8bacb1553 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -48,18 +48,6 @@ export function createWebConnectionRpc(): ClientConnectionRpc { } } -/** - * Create the fixture-mode caller, where no Host Remote registry exists. - * @returns caller that rejects every generic Remote invocation. - */ -export function createUnavailableConnectionRpc(): ClientConnectionRpc { - return { - call(channel, endpoint) { - return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`)) - }, - } -} - function resolveBase(): string { const location = (globalThis as { location?: { origin?: string } }).location return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 41e8e9b0e2..9d9bbd2f26 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -285,9 +285,37 @@ describe('connection client apply', () => { } }) - it('keeps generic Remote calls unavailable in the client-only fixture', async () => { + it('carries Goal Remotes over the same state as the client-only fixture API', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() - await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/) + const created = await handle.rpc.call('/api', 'goals/create', { + args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } }, + }) + expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } }) + if (!created.ok) throw new Error('fixture Goal create failed') + const ref = (created.value as { ref: { id: string; revision: number } }).ref + const edited = await handle.rpc.call('/api', 'goals/edit', { + args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } }, + }) + expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } }) + const editedRef = { id: ref.id, revision: 2 } + const paused = await handle.rpc.call('/api', 'goals/pause', { + args: { agentId: 'fx-alpha', ref: editedRef }, + }) + expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } }) + const resumed = await handle.rpc.call('/api', 'goals/resume', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } }, + }) + expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } }) + const completed = await handle.rpc.call('/api', 'goals/complete', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } }, + }) + expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } }) + await expect(handle.rpc.call('/api', 'goals/clear', { + args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } }, + })).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } }) + await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/) + await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } })) + .rejects.toThrow(/endpoint.*unavailable/) }) }) From 737c12935ac1c95bd4118422c19f283abdb540f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:04:42 +0800 Subject: [PATCH 53/88] fix(connection): share fixture goal revision lookup --- .../client/connection/src/client/fixture.ts | 29 +++++++++++-------- .../request-response.expected.json | 4 +-- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1a9841aece..776d21fd46 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1598,12 +1598,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { )) }, clear(id: SessionId, ref: FxGoalRef): RpcResult { - const missing = requireGoalSession(id) - if (missing !== undefined) return missing - const current = backscanGoal(logOf(id)) - if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { - return goalFailure('stale or missing goal revision') - } + const resolved = resolveGoal(id, ref) + if (!resolved.ok) return resolved + const current = resolved.value const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 } appendGoalChange(id, { kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(), @@ -1612,18 +1609,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } - /** Shared CAS mutation path behind the canonical Remote verbs. */ - function mutateGoal( - id: SessionId, - ref: FxGoalRef, - next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, - ): RpcResult { + /** Resolve one current goal revision for a canonical Remote mutation. */ + function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing const current = backscanGoal(logOf(id)) if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { return goalFailure('stale or missing goal revision') } + return { ok: true, value: current } + } + + /** Shared CAS mutation path behind the canonical Remote verbs. */ + function mutateGoal( + id: SessionId, + ref: FxGoalRef, + next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, + ): RpcResult { + const resolved = resolveGoal(id, ref) + if (!resolved.ok) return resolved + const current = resolved.value const goal = next(current) if (goal === undefined) { return goalFailure(`invalid goal transition from "${current.goal.phase}"`) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index b9d67f4bf6..e796de8a8a 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\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 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 `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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如果相关的本地检查需要使用构建后的包产物,请先构建一次:\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仓库类型检查会执行全仓 `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` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、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 2fe4a53557179de0fbebe4e83e8cb18e735f112b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:07:19 +0800 Subject: [PATCH 54/88] fix(typert): validate and mount remote contributions safely --- packages/host/api-gateway/src/client/index.ts | 72 ++++++++++++----- .../host/api-gateway/tests/client.spec.ts | 80 +++++++++++++++++++ packages/typert/generator/src/analyzer.ts | 4 +- packages/typert/generator/src/workspace.ts | 1 + .../generator/tests/remote-model.spec.ts | 33 +++++++- packages/typert/registry/src/service.ts | 2 +- packages/typert/registry/tests/typert.spec.ts | 2 +- packages/typert/type-meta/src/index.ts | 2 +- .../typert/type-meta/tests/type-meta.spec.ts | 2 + 9 files changed, 173 insertions(+), 25 deletions(-) diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 3fc8389079..5503fc3dcf 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -4,7 +4,7 @@ * lookup, invocation, or type exposure. */ -import { Service } from 'cordis' +import { Service, symbols } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -84,7 +84,13 @@ class ClientApiService extends Service implements ClientApi { let disposeMethods: () => void | Promise try { disposeMethods = callerCtx.effect(() => { - const installed = contribution.descriptors.map(descriptor => this.install(descriptor)) + const installed: Array<() => void> = [] + try { + for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) + } catch (error) { + for (const dispose of installed.reverse()) dispose() + throw error + } return () => { for (const dispose of installed.reverse()) dispose() } @@ -169,21 +175,27 @@ class ClientApiService extends Service implements ClientApi { private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { let namespace = this.direct.get(descriptor.namespace) + const fresh = namespace === undefined if (namespace === undefined) { namespace = { value: Object.create(null) as Record, tokens: new Map() } - this.direct.set(descriptor.namespace, namespace) Object.defineProperty(this, descriptor.namespace, { configurable: true, enumerable: true, value: namespace.value, }) } + try { + Object.defineProperty(namespace.value, descriptor.method, { + configurable: true, + enumerable: true, + value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), + }) + } catch (error) { + if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + throw error + } + if (fresh) this.direct.set(descriptor.namespace, namespace) namespace.tokens.set(descriptor.method, token) - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) return () => { /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ if (namespace.tokens.get(descriptor.method) !== token) return @@ -242,7 +254,7 @@ class ClientApiService extends Service implements ClientApi { `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } - const args: Record = {} + const args = Object.create(null) as Record if (projection !== undefined) { const binder = this.ownerCtx.typert.contexts.getClient(projection.context) if (binder === undefined) { @@ -281,9 +293,12 @@ type InvokeRemote = ( args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace extends Service { +class ScopedRemoteNamespace { + private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() + private provided = false + readonly name: string static assertMethodAvailable(namespace: string, method: string): void { if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { @@ -296,8 +311,12 @@ class ScopedRemoteNamespace extends Service { name: string, private readonly invokeRemote: InvokeRemote, ) { - super(ctx, name) + this.ctx = ctx this.ownerCtx = ctx + this.name = name + Object.defineProperty(this, symbols.tracker, { + value: { associate: name, property: 'ctx' }, + }) } assertMethodAvailable(method: string): void { @@ -309,15 +328,28 @@ class ScopedRemoteNamespace extends Service { install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.assertMethodAvailable(descriptor.method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, this) + const activate = this.methods.size === 0 const method = descriptor.method - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) + try { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { + return this.invokeRemote(descriptor, projection, token, this.ctx, args) + }, + }) + if (activate) { + if (this.provided) { + this.ownerCtx.set(this.name, this) + } else { + this.ownerCtx.reflect.provide(this.name, this) + this.provided = true + } + } + } catch (error) { + Reflect.deleteProperty(this, method) + throw error + } this.methods.add(method) } @@ -328,7 +360,7 @@ class ScopedRemoteNamespace extends Service { } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 5c2c427605..c1f94f2e44 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -321,6 +321,34 @@ describe('Client TypeRT API', () => { await disposeScoped() }) + it('rolls back earlier descriptors when a later descriptor fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'archive') throw new Error('fixture later-descriptor failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .toThrow('fixture later-descriptor failure') + } finally { + spy.mockRestore() + } + + expect((ctx.api as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + await retry() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -409,6 +437,33 @@ describe('Client TypeRT API', () => { expect((ctx.api as unknown as Record).goals).toBeUndefined() }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const { scope: _scope, ...base } = directDescriptor() + const descriptor: InvocationDescriptor = { + ...base, + id: '@fixture/goals#goals/prototype', + method: 'prototype', + parameters: [{ + name: 'value', + wire: '__proto__', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, + }], + } + const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + + const method = (ctx.api.goals as unknown as Record Promise>).prototype + await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) + const payload = call.mock.calls[0]?.[2] as { readonly args: Record } + expect(Object.getPrototypeOf(payload.args)).toBeNull() + expect(Object.hasOwn(payload.args, '__proto__')).toBe(true) + expect(payload.args.__proto__).toBe('wire-value') + await dispose() + }) + it('rolls back Remote registration when concrete method installation fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty @@ -423,6 +478,31 @@ describe('Client TypeRT API', () => { } finally { spy.mockRestore() } + + const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + await retry() + }) + + it('withdraws a fresh scoped Service when its first method fails to install', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'rename') throw new Error('fixture scoped installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .toThrow('fixture scoped installation failure') + } finally { + spy.mockRestore() + } + + expect(ctx.get('goals')).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + await retry() }) it('throws RPC failures with the structured error as its cause', async () => { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 16c30e8bc5..bc5024a7d8 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -2810,7 +2810,9 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return /^[A-Za-z0-9_$.-]+$/.test(value) + // Generation bootstraps workspace artifacts before dsh-type-meta is built, + // so this extraction-only copy must mirror isTypeRTRemoteSegment(). + return value !== '.' && value !== '..' && /^[A-Za-z0-9_$.-]+$/.test(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 6327872166..4a303c4bd4 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -90,6 +90,7 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } + if (artifact.face !== 'host') return const remoteExpected = { types: './lib/typert.remote-client.d.ts', default: './lib/typert.remote-client.js', diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 4f4f3ea7cb..27bdac2fac 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -274,7 +274,7 @@ export interface BoxPayload { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) - it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => { + it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable Remote alias %s', (alias) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace( ' @Remote\n async create(', @@ -301,6 +301,37 @@ export interface RemainingSchema { .toThrow('publishes Remote artifacts but has no Remote methods') }) + it('validates Remote artifacts only on the host face of a dual-face package', () => { + const root = copyFixture() + const manifestPath = join(root, 'packages/remote/package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + dshClient?: object + exports: Record + files: string[] + } + manifest.dshClient = {} + manifest.exports['./client'] = './src/client.ts' + manifest.exports['./client/typert'] = { + types: './lib/typert.client.d.ts', + default: './lib/typert.client.js', + } + manifest.files.push('lib/typert.client.js', 'lib/typert.client.d.ts') + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(root, 'tsconfig.client.json'), `${JSON.stringify({ + extends: './tsconfig.base.json', + files: [], + references: [{ path: './packages/remote' }], + }, null, 2)}\n`) + writeFileSync(join(root, 'packages/remote/src/client.ts'), `/** @typert schema */ +export interface ClientMarker { + readonly ready: boolean +} +`) + + expect(new WorkspaceTypertGenerator(root).generate().map(artifact => artifact.face)) + .toEqual(['host', 'client']) + }) + it.each([ { name: 'missing binding', diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 2f85138edd..3631253342 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -600,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - if (!/^[A-Za-z0-9_$.-]+$/.test(value)) { + if (value === '.' || value === '..' || !/^[A-Za-z0-9_$.-]+$/.test(value)) { throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) } } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 6661cbeeb4..29654babf7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -247,7 +247,7 @@ describe('TypertRegistry', () => { })).toThrow('endpoint "goals/create" is already registered') }) - it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => { + it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable invocation method %s', async (method) => { const ctx = await makeCtx() expect(() => ctx.typert.remotes.register({ package: '@fixture/invalid-endpoint', diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 67a4169f96..3d782dbb77 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -15,7 +15,7 @@ const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ * @returns whether the value can cross the shared RPC carrier unchanged. */ export function isTypeRTRemoteSegment(value: string): boolean { - return TYPERT_REMOTE_SEGMENT_PATTERN.test(value) + return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } export type { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 757488024d..b84b76300c 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -164,6 +164,8 @@ describe('type-meta Remote declarations', () => { expect(() => Remote('bad/name')).toThrow('export name') expect(() => Remote('bad#name')).toThrow('export name') expect(() => Remote('bad name')).toThrow('export name') + expect(() => Remote('.')).toThrow('export name') + expect(() => Remote('..')).toThrow('export name') expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') From d9413502278318ad27a849b01a2c59b8cefaea24 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:51:49 +0800 Subject: [PATCH 55/88] fix(typert): preserve remote lookup semantics --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 12 ++- ...026-08-02-typert-remote-method-calls.zh.md | 12 ++- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/cordis-catalog/services.md | 6 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 6 +- docs/core-data-structures/typert.zh.md | 6 +- packages/client/ui-goal/src/client/index.ts | 10 +- .../ui-goal/tests/browser-plugin.spec.tsx | 27 ++++-- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/api-gateway/README.i18n.yaml | 4 +- packages/host/api-gateway/README.md | 7 +- packages/host/api-gateway/README.zh.md | 7 +- packages/host/api-gateway/src/index.ts | 17 +++- packages/host/api-gateway/src/types.ts | 2 +- .../host/api-gateway/tests/client.spec.ts | 21 ++++ .../host/api-gateway/tests/gateway.spec.ts | 44 ++++++++- packages/host/apiproxy/package.json | 2 + packages/host/apiproxy/src/api-proxy.ts | 18 ++++ .../apiproxy/tests/api-proxy-cold.spec.ts | 96 +++++++++++++++++++ packages/host/apiproxy/tsconfig.json | 6 ++ packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 1 + packages/typert/registry/README.zh.md | 1 + packages/typert/registry/src/service.ts | 56 ++++++++++- packages/typert/registry/tests/typert.spec.ts | 34 +++++++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 +- packages/typert/type-meta/README.zh.md | 2 +- packages/typert/type-meta/src/index.ts | 20 ++++ packages/typert/type-meta/src/types.ts | 31 +++++- pnpm-lock.yaml | 6 ++ 35 files changed, 425 insertions(+), 65 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 02e6c428ff..c76dabca3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: ddc93b4fc672f320b4e3dc3e11586d92604e6aa4 -2026-08-02-typert-remote-method-calls.zh.md: 808c7d54bff19d9a4e9cf924769df1d405d997b5 +2026-08-02-typert-remote-method-calls.md: d91f6f173c1b56efcd21d3136392837e61f54aae +2026-08-02-typert-remote-method-calls.zh.md: 0c548522d1137f0e0002a740d12ca0b796da5e39 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index ddc93b4fc6..d91f6f173c 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -154,7 +154,7 @@ Descriptors exist only in the local registry on each side. The wire carries only ```text ctx.typert.local 当前进程自己的 Host 或 Client reflection ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 ctx.typert.contexts Host Context resolver 与 Client Context binder ``` @@ -162,6 +162,8 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. +Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. The standard Web Host's API Proxy configures the same `agentFor()` for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. + The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. ## Canonical types, symbols, and Zod @@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The current adapter converts every Gateway and business-invocation failure to the existing `RpcError` envelope with `code: 'internal'`; the Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. +Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypeRTLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. TypeRT endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. @@ -451,11 +453,12 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. +- API Proxy Host composition: configures cold resume, concurrent deduplication, and subagent ownership policy for `agent`/`session` lookups through the existing `agentFor()`. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. @@ -486,6 +489,7 @@ Connection supplies the shared-channel interceptor and current HTTP carrier mapp - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. +- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. - Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. @@ -514,3 +518,5 @@ Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted `hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a TypeRT registry restart forgets the historical strict definition. Cancellation-aware Remote signatures receive Connection's request `AbortSignal`, so an HTTP disconnect or Client-side abort reaches ongoing business work without entering the JSON protocol. Cancellation remains cooperative: methods without the reserved final parameter continue running, and a method that receives the signal must pass it to its own cancellable operations or observe it directly. + +Lookup configuration currently operates at key granularity, so every `agent` or `session` parameter uses the same cold-resume policy. A specific Remote that requires live-only semantics must wait for an explicit per-parameter or per-endpoint policy; the business implementation cannot be left to guess whether the object was just resumed. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 808c7d54bf..0c548522d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -154,7 +154,7 @@ descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、e ```text ctx.typert.local 当前进程自己的 Host 或 Client reflection ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 活对象的 provider +ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 ctx.typert.contexts Host Context resolver 与 Client Context binder ``` @@ -162,6 +162,8 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 +业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。标准 Web Host 的 API Proxy 为 `agent` 和 `session` 配置同一套 `agentFor()`:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 + Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 ## 唯一类型、符号与 Zod @@ -432,7 +434,7 @@ ctx.api.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。当前 adapter 把所有 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 +Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。TypeRT endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 @@ -451,11 +453,12 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 +- API Proxy Host 组合:用既有 `agentFor()` 配置 `agent`/`session` lookup 的冷恢复、并发去重和 subagent ownership 策略。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 @@ -486,6 +489,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 +- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 - 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 @@ -514,3 +518,5 @@ Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接 `hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 TypeRT 注册表才会忘记历史 strict definition。 支持取消的 Remote 签名会接收 Connection 请求的 `AbortSignal`,因此 HTTP 断连或 Client 侧 abort 能在不进入 JSON 协议的情况下传递到正在进行的业务工作。取消仍是协作式的:没有保留末位参数的方法会继续运行;收到 signal 的方法必须将它传给自身支持取消的操作,或自行观测它。 + +lookup 配置当前以 key 为粒度,因此每个 `agent` 或 `session` 参数都采用同一套冷恢复策略。需要 live-only 语义的特定 Remote 必须等待显式的逐参数或逐 endpoint 策略,不能靠业务实现猜测对象是否刚被恢复。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 87abb10c88..58891890d3 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: 76af93880d278a17dc46370fd5065fdcdadb9fb6 -api-gateway.zh.md: d447cea6b64bf88084f86a210a5f654bd9445d6c +api-gateway.md: 2e0717fd7b0e5b9ca33d650ffad7ac454046f780 +api-gateway.zh.md: 4d1beebf92cae702dac323cdd974b6220091a214 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 76af93880d..2e0717fd7b 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -8,7 +8,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. -`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to the current live object before invoking the business method. +`@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. `@RemoteContext(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. @@ -117,6 +117,8 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary before entering or after leaving business code. +The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The standard Web Host's API Proxy configures the same `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. + Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. ## SRC development fallback @@ -155,3 +157,5 @@ The running Client watcher consumes these generated files when it rebundles; wit ## Boundaries Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. + +Lookup policy is currently configured per key, so all `agent` or `session` parameters share the cold-resume behavior. If a Remote endpoint must accept live objects only, an explicit per-parameter or per-endpoint policy must be added later; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index d447cea6b6..4d1beebf92 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -8,7 +8,7 @@ 业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 -`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为当前的实时对象。 +`@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 `@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 @@ -117,6 +117,8 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 +lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。标准 Web Host 的 API Proxy 为 `agent` 与 `session` 配置同一套 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 + Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 ## SRC 开发回退 @@ -155,3 +157,5 @@ pnpm run build:lib:contracts ## 边界 Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 + +当前 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。某个 Remote endpoint 若必须只接受 live 对象,需要后续增加显式的逐参数或逐 endpoint 策略,不能通过业务方法内部猜测恢复来源。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8a646fc177..8acd669131 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2593,7 +2593,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:346`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:400`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` @@ -2604,12 +2604,12 @@ Resolve strict generated definitions or conservative SRC markers against current * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:76`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/host/api-gateway/src/index.ts:78`](../../packages/host/api-gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index a5484d06c4..5b0b70de54 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/typert.md -typert.md: da6e229ff6a2300c36f5734ad05c621a5e63082d -typert.zh.md: b3b0e8897756b5b4f9b645522cc5a1b27eac1d33 +typert.md: 1ff0fe80e483d481f686336c86038cdd169ecdbc +typert.zh.md: 3cc0aa26406e01db5a6c05074210fc9d40b8ec00 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index da6e229ff6..1ff0fe80e4 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -114,7 +114,7 @@ interface InvocationDescriptor { ## TypeRT registry -`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, live lookup providers, and scoped Context providers. Registrations are Cordis-owned effects and return awaitable disposers. +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. ```ts type-equiv /** Minimal TypeRT runtime consumed through dependency inversion. */ @@ -135,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, although the current RPC adapter folds them into the transport's `internal` error code. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, ordinary exceptions are folded by the RPC adapter into the transport's `internal` error code, and existing RPC errors carried by lookup policy through `TypeRTLookupFailure` are returned unchanged. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -180,7 +180,7 @@ interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index b3b0e88977..3cc0aa2640 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -114,7 +114,7 @@ interface InvocationDescriptor { ## TypeRT 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、活 lookup 提供方与 scoped Context 提供方。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与 scoped Context 提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal TypeRT runtime consumed through dependency inversion. */ @@ -135,7 +135,7 @@ interface TypeRTRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,但当前 RPC 适配器会把这些错误折叠为传输层的 `internal` 错误码。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,普通异常由 RPC 适配器折叠为传输层的 `internal` 错误码,lookup 策略通过 `TypeRTLookupFailure` 携带的既有 RPC error 则原样返回。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -180,7 +180,7 @@ interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 19b88139b5..8fcfd292d2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -70,8 +70,6 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') - const { goals } = ctx.api - const sessions = ctx.sessions /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ @@ -96,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.edit(sessionId, ref, { objective })) + return settle(ctx.api.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.pause(sessionId, ref)) + return settle(ctx.api.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.resume(sessionId, ref)) + return settle(ctx.api.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(goals.clear(sessionId, ref)) + return settle(ctx.api.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index eddb272be4..11c95e27d9 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -64,12 +64,16 @@ async function bench(options: { } } const ref = { id: 'g-1', revision: 3 } - ctx.provide('api', { goals: { - edit: answer('goals/edit', { ref }), - pause: answer('goals/pause', { ref }), - resume: answer('goals/resume', { ref }), - clear: answer('goals/clear', ref), - } }) + const goals = (prefix: string) => ({ + edit: answer(`${prefix}/edit`, { ref }), + pause: answer(`${prefix}/pause`, { ref }), + resume: answer(`${prefix}/resume`, { ref }), + clear: answer(`${prefix}/clear`, ref), + }) + let activeGoals = goals('goals') + ctx.provide('api', { + get goals() { return activeGoals }, + }) await ctx.plugin(SlotsService).await() ctx.slots.register({ name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, @@ -90,6 +94,7 @@ async function bench(options: { ctx, fiber, calls, + remountGoals: () => { activeGoals = goals('remounted-goals') }, entry: () => { const entry = ctx.slots.entries('conversation.input.dock')[0] if (entry === undefined) return undefined @@ -126,6 +131,16 @@ describe('ui-goal browser plugin', () => { expect(b.calls[3]?.args).toEqual(['s1', ref]) }) + it('verbs read a remounted Remote namespace at action time', async () => { + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + b.remountGoals() + + expect(await verbs.onPause()).toEqual({ ok: true }) + expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) + }) + it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { const b = await bench({ projection }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2627d43b69..b7fd6d3c5a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1160,7 +1160,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async invoke(request: InvokeRemoteRequest): Promise', - jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */', + jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.\n */', }, ], }, diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/host/api-gateway/README.i18n.yaml index 273a493c24..8d8d699c7a 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/host/api-gateway/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/api-gateway/README.md -README.md: 43e8f464e2a2790d05628a7fba61143a6a5ab26a -README.zh.md: 761045d0c1afc17dfc230f9f45849c46e4e579fc +README.md: eb48c29628d39e381235b1f72754eb114960b1ad +README.zh.md: e53bb6c216e42fe2e970bf2cb80eac9ea7426497 diff --git a/packages/host/api-gateway/README.md b/packages/host/api-gateway/README.md index 43e8f464e2..eb48c29628 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/host/api-gateway/README.md @@ -8,9 +8,9 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. @@ -32,7 +32,8 @@ No direct effect; invoked business Services own any model-visible result. ## Known Limitations and Deferred Work -- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers. +- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. - The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. +- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. diff --git a/packages/host/api-gateway/README.zh.md b/packages/host/api-gateway/README.zh.md index 761045d0c1..e53bb6c216 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/host/api-gateway/README.zh.md @@ -8,9 +8,9 @@ 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 @@ -32,7 +32,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle ## 已知限制与延期工作 -- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 - 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 +- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index 7dd2410873..8ea26b5990 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -8,6 +8,7 @@ import { Context, Service, symbols } from 'cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, + TypeRTLookupFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypeRTCodec, @@ -36,6 +37,7 @@ interface ResolvedBinding { } type ConnectionRpcResult = Awaited> +type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal /** Dispatch failure produced outside the invoked business method. */ @@ -126,7 +128,7 @@ export class TypertGatewayService extends Service implements TypertGateway { * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { const endpoint = endpointOf(request.namespace, request.method) @@ -142,7 +144,8 @@ export class TypertGatewayService extends Service implements TypertGateway { ) } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) - const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint)) + const args = await Promise.all(descriptor.parameters.map(parameter => + this.resolveParameter(parameter, request.args, endpoint))) if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) const implementation = descriptor.implementation ?? descriptor.method const method = Reflect.get(receiver, implementation) as unknown @@ -375,11 +378,11 @@ export class TypertGatewayService extends Service implements TypertGateway { return context } - private resolveParameter( + private async resolveParameter( parameter: InvocationParameterDescriptor, args: Readonly>, endpoint: string, - ): unknown { + ): Promise { const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup @@ -412,8 +415,9 @@ export class TypertGatewayService extends Service implements TypertGateway { } let resolved: unknown try { - resolved = provider.resolve(value) + resolved = await provider.resolve(value) } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw cause throw new TypertGatewayError( 'lookup-failed', endpoint, @@ -434,6 +438,9 @@ export class TypertGatewayService extends Service implements TypertGateway { } function rpcFailure(error: unknown): ConnectionRpcResult { + if (error instanceof TypeRTLookupFailure) { + return { ok: false, error: error.failure as ConnectionRpcError } + } return { ok: false, error: { diff --git a/packages/host/api-gateway/src/types.ts b/packages/host/api-gateway/src/types.ts index b7f36eb340..f4bb276c22 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/host/api-gateway/src/types.ts @@ -41,7 +41,7 @@ export interface TypertGateway { * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. * @returns the validated business result. - * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index c1f94f2e44..2fbcbb9280 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -484,6 +484,27 @@ describe('Client TypeRT API', () => { await retry() }) + it('withdraws a fresh direct namespace when its first method fails to install', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'create') throw new Error('fixture direct method installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] })) + .toThrow('fixture direct method installation failure') + } finally { + spy.mockRestore() + } + + expect((ctx.api as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + await retry() + }) + it('withdraws a fresh scoped Service when its first method fails to install', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index aebe23da57..0871dc2761 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -9,6 +9,7 @@ import { bindTypeRTGateway, Remote, RemoteContext, + TypeRTLookupFailure, type InvocationDescriptor, type TypeRTContext, type TypeRTLookup, @@ -91,7 +92,7 @@ class GoalService extends Service { type FakeRpcResult = | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise @@ -568,7 +569,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [createDescriptor()]) const throwing = ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => { throw new Error('lookup failed') }, + resolve: async () => { throw new Error('lookup failed') }, }) const failure = await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', @@ -578,15 +579,26 @@ describe('TypertGatewayService', () => { expect(failure.cause).toEqual(new Error('lookup failed')) await throwing() - ctx.typert.lookups.register('gatewayFixture', { + const missing = ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => undefined, + resolve: () => Promise.resolve(undefined), }) await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, }), 'lookup-not-found') + await missing() + + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: async id => ({ id }), + }) + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + })).resolves.toMatchObject({ agentId: 'agent-1', title: 'ship' }) }) it('never downgrades an observed strict endpoint after definition disposal', async () => { @@ -968,6 +980,30 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('preserves a lookup policy rejection through the Connection RPC result', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + await ctx.plugin(GoalService) + registerStrict(ctx, [createDescriptor()]) + const failure = { + code: 'agent-busy', + message: 'session is owned by subagent routing', + details: { reason: 'use subagent delivery for this child session' }, + } + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => { throw new TypeRTLookupFailure(failure) }, + }) + const handler = rawConnection(ctx).handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') + + await expect(handler('goals/create', { + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }, new AbortController().signal)).resolves.toEqual({ ok: false, error: failure }) + }) + it('caches SRC ownership until the Cordis Service set changes', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 426860eebc..ce740a025f 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -56,6 +56,8 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 2a54e2c113..f2c199feca 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -19,6 +19,9 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +// Type-only: resolves the optional `ctx.typert` lookup-policy composition. +import type {} from '@deepseek-ai/dsh-typert-registry' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, @@ -1099,6 +1102,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + // Remote object parameters use the same identity policy as API Proxy methods: + // ordinary cold sessions resume once, while subagent-owned identities retain + // their stable caller-facing rejection. The provider packages continue to + // own wire declarations and live-only defaults; this Host composition owns + // the broader lookup policy. + ctx.inject(['typert'], (typeCtx) => { + const resolveAgent = async (sessionId: SessionId): Promise => { + const found = await agentFor(sessionId) + if ('error' in found) throw new TypeRTLookupFailure(found.error) + return found.agent + } + typeCtx.typert.lookups.configure('agent', resolveAgent) + typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + }) + type SessionReadState = { id: SessionId header: SessionHeader diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 4b6337ede8..e5e137f0c4 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -11,6 +11,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -180,6 +182,100 @@ describe('cold history recovery view', () => { }) }) +describe('Remote Agent and Session lookup policy', () => { + it('deduplicates a cold resume across Agent and Session parameters', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-remote-cold') + const meta = header(sessionId, 1000) + const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + } as never) + const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session + const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent + const release = Promise.withResolvers() + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + await release.promise + return { agent: resumedAgent, dispose: () => Promise.resolve() } + }) + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + + const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId)) + const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId)) + await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() }) + release.resolve(undefined) + + await expect(resolvedAgent).resolves.toBe(resumedAgent) + await expect(resolvedSession).resolves.toBe(resumedSession) + expect(inspect).toHaveBeenCalledOnce() + }) + + it('preserves the subagent ownership fence for cold and live Remote lookups', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const coldId = sid('session-remote-cold-child') + const coldMeta = header(coldId, 1000, { + parentSession: sid('session-parent'), + origin: 'subagent', + }) + const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([coldMeta]), + inspect, + locate: () => undefined, + } as never) + const liveSession = ctx.sessions.create(sid('session-remote-live-child'), { + meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' }, + }) + const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent + ctx.agents.register(liveAgent) + const resume = vi.spyOn(ctx.agents, 'resume') + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + const ownershipFailure = { + failure: { + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }, + } + + const coldFailure = Promise.resolve(agentLookup.resolve(coldId)) + const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id)) + await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(coldFailure).rejects.toMatchObject(ownershipFailure) + await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(liveFailure).rejects.toMatchObject(ownershipFailure) + expect(resume).not.toHaveBeenCalled() + expect(inspect).toHaveBeenCalledOnce() + }) +}) + describe('subagent ownership fence', () => { it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { const ctx = new Context() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index c648d7a30d..23c170f4fd 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/tools" }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" + }, { "path": "../../session-persistence/session-persistence" }, diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index b8c97637c9..a6180c6bfc 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/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/registry/README.md -README.md: 83c03ab284abf2b7cab4dd1ee70d7e855184a1e0 -README.zh.md: db2140e51d85be53bbf6eb4d1dd86ec38ceefc58 +README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 +README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index 83c03ab284..dae8c3ed12 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -9,6 +9,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly resolvers = new Map>() private readonly definitions = new Map() private readonly changes: ChangeSource @@ -229,13 +231,61 @@ class LookupStore { TypeRTLookupWire >, ) => this.register(ctx, key, provider), - get: key => this.providers.get(key)?.provider, + configure: >( + key: K, + resolver: TypeRTLookupResolver< + TypeRTLookupHost, + TypeRTLookupWire + >, + ) => this.configure(ctx, key, resolver), + get: key => this.get(key), definitions: () => [...this.definitions.values()], keys: () => [...this.providers.keys()], subscribe: listener => this.changes.subscribe(ctx, listener), } } + private get(key: string): TypeRTLookupProvider | undefined { + const provider = this.providers.get(key)?.provider + if (provider === undefined) return undefined + const resolver = this.resolvers.get(key)?.provider + if (resolver === undefined) return provider + return { + parameter: provider.parameter, + wire: provider.wire, + hostTypeSymbol: provider.hostTypeSymbol, + wireTypeSymbol: provider.wireTypeSymbol, + resolve: id => resolver.resolve(id), + } + } + + private configure( + ctx: Context, + key: string, + resolver: TypeRTLookupResolver, + ): TypeRTDisposer { + validateSegment('lookup key', key) + if (this.resolvers.has(key)) throw new Error(`typert: lookup "${key}" resolver is already configured`) + const owner = {} + // The map erases each merge-declared Wire type; restore it only at the + // typed configure() boundary so strict function variance remains sound. + const entry: ProviderEntry = { + provider: { resolve: async id => resolver(id as Wire) }, + owner, + } + const { resolvers, changes } = this + return ctx.effect(function* () { + resolvers.set(key, entry) + changes.emit({ kind: 'lookup', key }) + yield () => { + /* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */ + if (resolvers.get(key) !== entry) return + resolvers.delete(key) + changes.emit({ kind: 'lookup', key }) + } + }, `typert.lookups.configure(${JSON.stringify(key)})`) + } + private register(ctx: Context, key: string, provider: TypeRTLookupProvider): TypeRTDisposer { validateSegment('lookup key', key) validateSegment('lookup parameter', provider.parameter) @@ -271,6 +321,10 @@ class LookupStore { } } +interface LookupResolverEntry { + resolve(id: unknown): Promise +} + function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean { return left.parameter === right.parameter && left.wire === right.wire diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 29654babf7..087cf00fc4 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -355,6 +355,40 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('configures an asynchronous lookup resolver independently of provider load order', async () => { + const ctx = await makeCtx() + const fallback = { id: 'fallback' } + const configured = { id: 'configured' } + const disposeResolver = ctx.typert.lookups.configure('fixture', async id => + id === configured.id ? configured : undefined) + + expect(ctx.typert.lookups.get('fixture')).toBeUndefined() + const disposeProvider = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === fallback.id ? fallback : undefined, + }) + await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured) + expect(() => ctx.typert.lookups.configure('fixture', () => undefined)).toThrow('already configured') + + await disposeProvider() + expect(ctx.typert.lookups.get('fixture')).toBeUndefined() + const disposeReloadedProvider = ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/agent#Agent', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === fallback.id ? fallback : undefined, + }) + await expect(ctx.typert.lookups.get('fixture')?.resolve('configured')).resolves.toBe(configured) + + await disposeResolver() + expect(ctx.typert.lookups.get('fixture')?.resolve('fallback')).toBe(fallback) + await disposeReloadedProvider() + }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { const ctx = await makeCtx() const changes: string[] = [] diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index a3e0643ace..510b8d3854 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/type-meta/README.md -README.md: 245df305efcf711486b2d3f32e40a8b415f2682e -README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6 +README.md: b394c843409e840b75bbb08b128614379e528001 +README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 245df305ef..b394c84340 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 592aa5d027..5bd9bb1828 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 -查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 3d782dbb77..7ded29fa4a 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -18,6 +18,25 @@ export function isTypeRTRemoteSegment(value: string): boolean { return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } +/** + * A lookup policy rejection whose typed payload belongs to the active boundary adapter. + * Gateway adapters preserve this payload instead of collapsing it into an infrastructure failure. + */ +export class TypeRTLookupFailure extends Error { + /** Adapter-owned failure returned to the caller. */ + readonly failure: Failure + + /** + * Wrap one adapter failure without exposing the rejected identity. + * @param failure - typed failure owned by the active boundary adapter. + */ + constructor(failure: Failure) { + super('TypeRT lookup policy rejected the requested identity') + this.name = 'TypeRTLookupFailure' + this.failure = failure + } +} + export type { InvocationDescriptor, InvocationParameterDescriptor, @@ -36,6 +55,7 @@ export type { TypeRTLookupHost, TypeRTLookupMap, TypeRTLookupProvider, + TypeRTLookupResolver, TypeRTLookupRegistry, TypeRTLookupWire, TypeRTRemoteContextApi, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 6de5c7f823..7831c08e37 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -176,7 +176,16 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } -/** Runtime resolver for one declared Host object lookup. */ +/** + * Resolve one validated wire identity, synchronously or asynchronously. + * @param id - validated wire identity. + * @returns the Host object, or `undefined` when unavailable. + */ +export type TypeRTLookupResolver = ( + id: Wire, +) => Host | undefined | Promise + +/** Runtime provider for one declared Host object lookup. */ export interface TypeRTLookupProvider { /** Source parameter name recognized by the SRC weak parser. */ readonly parameter: string @@ -187,11 +196,11 @@ export interface TypeRTLookupProvider { /** Canonical wire type symbol used by strict generation. */ readonly wireTypeSymbol: string /** - * Resolve a wire identity to the current live Host object. + * Resolve a wire identity through the provider's default policy. * @param id - validated wire identity. - * @returns the live object, or `undefined` when it is unavailable. + * @returns the object, `undefined` when unavailable, or either asynchronously. */ - resolve(id: Wire): Host | undefined + resolve(id: Wire): Host | undefined | Promise } /** Stable wire declaration retained after a lookup provider unloads. */ @@ -304,6 +313,20 @@ export interface TypeRTLookupRegistry { TypeRTLookupWire >, ): TypeRTDisposer + /** + * Replace one provider's default resolution policy while this contribution is active. + * Configuration may precede provider registration; without a live provider, `get()` remains unavailable. + * @param key - lookup key whose wire declaration remains provider-owned. + * @param resolver - composition-owned resolver used by every lookup of this key. + * @returns disposer restoring the provider's default resolver. + */ + configure>( + key: K, + resolver: TypeRTLookupResolver< + TypeRTLookupHost, + TypeRTLookupWire + >, + ): TypeRTDisposer /** * Look up one provider by runtime key. * @param key - descriptor lookup key. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79d38a43cd..e0adfb22c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3838,6 +3838,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval From bb61dc13f221fb9052a52a0c7e337fbd8e4c5898 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:48:29 +0800 Subject: [PATCH 56/88] refactor(api): colocate gateway and remote assembly --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 24 ++- ...026-08-02-typert-remote-method-calls.zh.md | 24 ++- AGENTS.md | 1 + apps/cli/composition.md | 4 +- apps/web/tests/assembled-boot.ts | 6 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 21 +- docs/api-gateway.zh.md | 21 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 8 +- docs/core-data-structures/typert.zh.md | 8 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 39 +++- knip.json | 2 +- packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../{client/remotes => api}/README.i18n.yaml | 6 +- packages/api/README.md | 17 ++ packages/api/README.zh.md | 17 ++ .../gateway}/README.i18n.yaml | 6 +- .../api-gateway => api/gateway}/README.md | 6 +- .../api-gateway => api/gateway}/README.zh.md | 6 +- .../api-gateway => api/gateway}/package.json | 4 +- .../gateway}/src/client/index.ts | 16 +- .../api-gateway => api/gateway}/src/index.ts | 2 +- .../gateway}/src/invariant.ts | 8 +- .../api-gateway => api/gateway}/src/types.ts | 2 +- .../gateway}/tests/client.spec.ts | 0 .../gateway}/tests/gateway.spec.ts | 2 +- .../api-gateway => api/gateway}/tsconfig.json | 0 packages/api/gateway/tsdown.config.ts | 3 + packages/api/remotes/README.i18n.yaml | 6 + packages/api/remotes/README.md | 25 +++ packages/api/remotes/README.zh.md | 25 +++ packages/{client => api}/remotes/package.json | 19 +- packages/api/remotes/src/agent-lookup.ts | 193 ++++++++++++++++++ .../remotes/src/client/index.ts | 11 +- packages/api/remotes/src/index.ts | 18 ++ .../{client => api}/remotes/src/invariant.ts | 8 +- .../remotes/tests/built-lib.e2e.ts | 16 +- .../{client => api}/remotes/tsconfig.json | 14 +- packages/api/remotes/tsdown.config.ts | 3 + packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 +- packages/bundle/web-app/package.json | 2 +- packages/client/remotes/README.md | 22 -- packages/client/remotes/README.zh.md | 22 -- packages/client/remotes/src/index.ts | 4 - packages/client/remotes/tsdown.config.ts | 3 - packages/client/runtime/package.json | 6 +- packages/client/runtime/src/client/index.ts | 2 +- packages/client/runtime/tsconfig.json | 2 +- packages/client/ui-goal/package.json | 6 +- packages/client/ui-goal/src/client/index.ts | 2 +- packages/client/ui-goal/tsconfig.json | 2 +- packages/host/api-gateway/tsdown.config.ts | 3 - packages/host/apiproxy/package.json | 5 +- packages/host/apiproxy/src/api-proxy.ts | 161 ++------------- packages/host/apiproxy/tsconfig.json | 9 +- packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 10 + pnpm-lock.yaml | 130 ++++++------ scripts/gen-cordis-catalog.ts | 2 +- scripts/run-gates.ts | 2 +- scripts/type-equiv.manifest.json | 10 +- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 12 +- tsconfig.client.json | 4 +- tsconfig.host.json | 2 +- vitest.config.ts | 4 +- 82 files changed, 645 insertions(+), 432 deletions(-) rename packages/{client/remotes => api}/README.i18n.yaml (56%) create mode 100644 packages/api/README.md create mode 100644 packages/api/README.zh.md rename packages/{host/api-gateway => api/gateway}/README.i18n.yaml (56%) rename packages/{host/api-gateway => api/gateway}/README.md (86%) rename packages/{host/api-gateway => api/gateway}/README.zh.md (86%) rename packages/{host/api-gateway => api/gateway}/package.json (92%) rename packages/{host/api-gateway => api/gateway}/src/client/index.ts (96%) rename packages/{host/api-gateway => api/gateway}/src/index.ts (99%) rename packages/{host/api-gateway => api/gateway}/src/invariant.ts (77%) rename packages/{host/api-gateway => api/gateway}/src/types.ts (97%) rename packages/{host/api-gateway => api/gateway}/tests/client.spec.ts (100%) rename packages/{host/api-gateway => api/gateway}/tests/gateway.spec.ts (99%) rename packages/{host/api-gateway => api/gateway}/tsconfig.json (100%) create mode 100644 packages/api/gateway/tsdown.config.ts create mode 100644 packages/api/remotes/README.i18n.yaml create mode 100644 packages/api/remotes/README.md create mode 100644 packages/api/remotes/README.zh.md rename packages/{client => api}/remotes/package.json (64%) create mode 100644 packages/api/remotes/src/agent-lookup.ts rename packages/{client => api}/remotes/src/client/index.ts (64%) create mode 100644 packages/api/remotes/src/index.ts rename packages/{client => api}/remotes/src/invariant.ts (70%) rename packages/{client => api}/remotes/tests/built-lib.e2e.ts (95%) rename packages/{client => api}/remotes/tsconfig.json (63%) create mode 100644 packages/api/remotes/tsdown.config.ts delete mode 100644 packages/client/remotes/README.md delete mode 100644 packages/client/remotes/README.zh.md delete mode 100644 packages/client/remotes/src/index.ts delete mode 100644 packages/client/remotes/tsdown.config.ts delete mode 100644 packages/host/api-gateway/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index c76dabca3c..9ba0cf8dc1 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: d91f6f173c1b56efcd21d3136392837e61f54aae -2026-08-02-typert-remote-method-calls.zh.md: 0c548522d1137f0e0002a740d12ca0b796da5e39 +2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 +2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index d91f6f173c..c4f3a5b94b 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -20,7 +20,9 @@ A business Service extends `GatewayService` and declares callable methods with ` The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-host-api-gateway`, located at `packages/host/api-gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. + +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientApi` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -29,10 +31,10 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | `@deepseek-ai/dsh-type-meta` | Declares only the minimal `ctx.typert` protocol | `GatewayService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | TypeRT registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | -| Host API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | +| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | -| Host API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | -| Client Remotes | No new service | Serves as the only Remote facade for Client business code, selecting and mounting `/remote` contributions while exposing the Gateway Client face and the selected API declarations | +| API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | | Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | @@ -162,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. -Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. The standard Web Host's API Proxy configures the same `agentFor()` for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. +Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -295,7 +297,7 @@ TypeRT.local 当前环境自己的反射模型 TypeRT.remotes 已导入的 Remote contribution ``` -`@deepseek-ai/dsh-client-remotes/client` centrally loads the required Remote contributions: +`@deepseek-ai/dsh-api-remotes/client` centrally loads the required Remote contributions: ```text import goalsRemote from '@deepseek-ai/dsh-goal/remote' @@ -305,7 +307,7 @@ ctx.api.mount(goalsRemote) ctx.api.mount(sessionsRemote) ``` -Client business packages depend only on `@deepseek-ai/dsh-client-remotes/client`, not directly on the Host API Gateway or the runtime entry of each business `/remote`. Client Remotes itself depends on the Gateway Client face and re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. +Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientApi` contract and Cordis `ctx.api` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. `ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. @@ -449,11 +451,11 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-host-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-client-remotes`: the only Remote facade depended on by Client business code; directly depends on the Gateway Client face, selects `/remote` contributions, and exposes the merged API types to business packages. +- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged API types to business packages through the shared `TypeRTClientApi` contract. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. -- API Proxy Host composition: configures cold resume, concurrent deduplication, and subagent ownership policy for `agent`/`session` lookups through the existing `agentFor()`. +- API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work @@ -462,6 +464,8 @@ The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client AP Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. +The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. The legacy API Proxy likewise remains under `host/apiproxy` as the fallback for methods not yet migrated to Remote. + ## Alternatives considered **Continue using the central API Proxy package.** This would require business methods, Host routes, and Client interfaces to be declared repeatedly in several locations. It would also keep direct calls, stateful interactions, and event streams tied to the same lifecycle, so this alternative is rejected. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 0c548522d1..e11d8ebe42 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -20,7 +20,9 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-host-api-gateway` 在 `packages/host/api-gateway` 内提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 + +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 契约,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -29,10 +31,10 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | `@deepseek-ai/dsh-type-meta` | 只声明 `ctx.typert` 的最小协议 | `GatewayService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | TypeRT registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | -| Host API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | +| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | -| Host API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | -| Client Remotes | 无新增服务 | 作为 Client 业务的唯一 Remote facade,选择并挂载 `/remote` contribution,同时传递 Gateway Client face 和所选 API 的类型声明 | +| API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | | Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | @@ -162,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。标准 Web Host 的 API Proxy 为 `agent` 和 `session` 配置同一套 `agentFor()`:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 +业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -295,7 +297,7 @@ TypeRT.local 当前环境自己的反射模型 TypeRT.remotes 已导入的 Remote contribution ``` -`@deepseek-ai/dsh-client-remotes/client` 集中加载需要的 Remote contribution: +`@deepseek-ai/dsh-api-remotes/client` 集中加载需要的 Remote contribution: ```text import goalsRemote from '@deepseek-ai/dsh-goal/remote' @@ -305,7 +307,7 @@ ctx.api.mount(goalsRemote) ctx.api.mount(sessionsRemote) ``` -Client 业务包只引用 `@deepseek-ai/dsh-client-remotes/client`,不直接依赖 Host API Gateway 或各业务 `/remote` 运行时入口。Client Remotes 自己依赖 Gateway Client face,并通过声明 re-export 把所选 Remote map 传给业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientApi` 契约和 Cordis `ctx.api` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 `ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 @@ -449,11 +451,11 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-host-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-client-remotes`:Client 业务唯一依赖的 Remote facade;直接依赖 Gateway Client face,选择 `/remote` contributions,并向业务包传递合并后的 API 类型。 +- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientApi` 契约向业务包暴露合并后的 API 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 -- API Proxy Host 组合:用既有 `agentFor()` 配置 `agent`/`session` lookup 的冷恢复、并发去重和 subagent ownership 策略。 +- API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 @@ -462,6 +464,8 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 +包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。旧 API Proxy 同样保留在 `host/apiproxy` 下,作为尚未迁移到 Remote 的方法的回退路径。 + ## Alternatives considered **继续使用中央 API Proxy 包。** 该方案要求业务方法、Host 路由和 Client 接口在多个位置重复声明,也会继续把直接调用、带状态交互和事件流绑在同一生命周期中,因此不采用。 diff --git a/AGENTS.md b/AGENTS.md index 0d27b20df0..c265d3cf32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop + api/ Remote BFF assembly and TypeRT RPC gateway typert/ type graph generator, loader, and runtime registry llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) bash/ bash executor seam + local/pwsh impls + model-facing shell tools diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0246f6163f..45dc52561a 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -22,7 +22,7 @@ flowchart LR cfg --> plugin_dsh_base_typert plugin_dsh_base_typert_loader["typert-loader
@deepseek-ai/dsh-typert-loader"] cfg --> plugin_dsh_base_typert_loader - plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-host-api-gateway"] + plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-api-gateway"] cfg --> plugin_dsh_base_typert_gateway plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title @@ -167,7 +167,7 @@ flowchart LR | `session` | `@deepseek-ai/dsh-session` | | `typert` | `@deepseek-ai/dsh-typert-registry` | | `typert-loader` | `@deepseek-ai/dsh-typert-loader` | -| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` | +| `typert-gateway` | `@deepseek-ai/dsh-api-gateway` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` | diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index ebb2aa513a..729428e47b 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -18,9 +18,9 @@ import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true }, { 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-host-api-gateway', bundlePath: 'packages/host/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-client-remotes', bundlePath: 'packages/client/remotes/lib/client.js', url: '/plugins/client-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-host-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-client-remotes', '@deepseek-ai/dsh-typert-registry'], 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-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/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 58891890d3..05038eb8b9 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: 2e0717fd7b0e5b9ca33d650ffad7ac454046f780 -api-gateway.zh.md: 4d1beebf92cae702dac323cdd974b6220091a214 +api-gateway.md: 090758d58306d5ea806567f0de710a1c1f5ed747 +api-gateway.zh.md: 9d7286b6b86918f3bc1e7a6cdd9bdf04447abc57 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2e0717fd7b..090758d583 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -61,7 +61,7 @@ The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' declare const ctx: Context declare const agentCtx: AgentContext @@ -71,9 +71,9 @@ await ctx.api.goals.create(agentId, { objective: 'ship it' }) await agentCtx.goals.create({ objective: 'ship it' }) ``` -Client applications assemble only `@deepseek-ai/dsh-client-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the Host API Gateway or the business package's Remote JS separately. +Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. -A future TUI can assemble the same React-independent `client-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. +A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. ## Component responsibilities @@ -82,12 +82,13 @@ A future TUI can assemble the same React-independent `client-remotes` and `ctx.a | Shared | `@deepseek-ai/dsh-type-meta` | Declares decorators, Gateway bindings, merge-extensible protocol maps, invocation descriptors, and provider types; starts no TypeScript analysis and registers no Cordis services | | Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | -| Host | `@deepseek-ai/dsh-host-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | -| Client | `@deepseek-ai/dsh-host-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | -| Client | `@deepseek-ai/dsh-client-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | +| Host | `@deepseek-ai/dsh-api-remotes` | Owns the application Agent/Session identity policy and configures the corresponding TypeRT lookups | +| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and current `/api` HTTP bridge | -The Host API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. +The API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. ## Strict generation pipeline @@ -99,7 +100,7 @@ Each contributing business package writes generated files to its own `lib/` dire |---|---|---| | `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | | `typert.host.d.ts` | Host type system | Generated declarations for the Host face | -| `typert.remote-client.js` | `client-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | +| `typert.remote-client.js` | `api-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | | `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | | `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | @@ -117,7 +118,7 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary before entering or after leaving business code. -The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The standard Web Host's API Proxy configures the same `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. +The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. API Remotes owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. The Web API Proxy supplies its Agent defaults and scope setup, then consumes the same resolver for legacy methods. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. @@ -158,4 +159,6 @@ The running Client watcher consumes these generated files when it rebundles; wit Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. +The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and TypeRT RPC layers live under `packages/api`; Connection and WebServer remain at `packages/client/connection` and `packages/host/webserver`, with service contracts that permit a later package-only move to `packages/api`. The legacy API Proxy remains at `packages/host/apiproxy` as the fallback for endpoints not yet migrated to Remote. + Lookup policy is currently configured per key, so all `agent` or `session` parameters share the cold-resume behavior. If a Remote endpoint must accept live objects only, an explicit per-parameter or per-endpoint policy must be added later; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4d1beebf92..9d7286b6b8 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -61,7 +61,7 @@ Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直 import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' declare const ctx: Context declare const agentCtx: AgentContext @@ -71,9 +71,9 @@ await ctx.api.goals.create(agentId, { objective: 'ship it' }) await agentCtx.goals.create({ objective: 'ship it' }) ``` -Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 Host API Gateway 或业务包的 Remote JS。 +Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 -未来的 TUI 可以装配同一个不依赖 React 的 `client-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 ## 组件职责 @@ -82,12 +82,13 @@ Client 应用只装配 `@deepseek-ai/dsh-client-remotes`。该包以运行时值 | 共享 | `@deepseek-ai/dsh-type-meta` | 声明 decorator、Gateway binding、可合并协议映射、调用描述符及提供方类型;不启动 TypeScript 分析,也不注册 Cordis 服务 | | 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | -| Host | `@deepseek-ai/dsh-host-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | -| Client | `@deepseek-ai/dsh-host-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | -| Client | `@deepseek-ai/dsh-client-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | +| Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | +| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | -Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 +API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 ## 严格生成链路 @@ -99,7 +100,7 @@ Host API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入 |---|---|---| | `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | | `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | -| `typert.remote-client.js` | `client-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | +| `typert.remote-client.js` | `api-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | | `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | | `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | @@ -117,7 +118,7 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 -lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。标准 Web Host 的 API Proxy 为 `agent` 与 `session` 配置同一套 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。API Remotes 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 @@ -158,4 +159,6 @@ pnpm run build:lib:contracts Remote 只处理有单个请求与单个结果的一元方法调用。Session event stream、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 TypeRT RPC 层位于 `packages/api`;Connection 与 WebServer 仍位于 `packages/client/connection` 和 `packages/host/webserver`,其服务契约允许未来只移动包,将它们放到 `packages/api`。旧 API Proxy 仍位于 `packages/host/apiproxy`,作为尚未迁移到 Remote 的 endpoint 的回退路径。 + 当前 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。某个 Remote endpoint 若必须只接受 live 对象,需要后续增加显式的逐参数或逐 endpoint 策略,不能通过业务方法内部猜测恢复来源。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 774bc296b1..0164acefcf 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: db5991d98dfbc6b04992d62d5a465c375c9a78b8 -architecture.zh.md: 2eb8c3834a6ffc3283c8aa669be481b534bb5914 +architecture.md: 35a73d4a307f5f48cc41cc496742a2ac210e8877 +architecture.zh.md: 185958221a477bb690e3ab5c91c33ba892ab2d73 diff --git a/docs/architecture.md b/docs/architecture.md index db5991d98d..35a73d4a30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | -| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | +| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 2eb8c3834a..185958221a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,7 +48,7 @@ | `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | -| `ctx.typertGateway` | [`host/api-gateway`](../packages/host/api-gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | +| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 18839bf3c2..2de89ef94b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -367,8 +367,8 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | -| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `api-gateway` | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | -| `ctx.typertGateway` | `core` | `api-gateway` | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | +| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | +| `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 77baad512d..21f38d18e2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2520,9 +2520,10 @@ Source: [`packages/context/workspace-context/src/config.ts:18`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) +- `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-remotes` ([`packages/client/remotes/src/index.ts`](../packages/client/remotes/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) @@ -2549,7 +2550,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) -- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8acd669131..cf763dca98 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2609,7 +2609,7 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/host/api-gateway/src/index.ts:78`](../../packages/host/api-gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:78`](../../packages/api/gateway/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index 5b0b70de54..a6e1eb5415 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/typert.md -typert.md: 1ff0fe80e483d481f686336c86038cdd169ecdbc -typert.zh.md: 3cc0aa26406e01db5a6c05074210fc9d40b8ec00 +typert.md: a61ed8587833e03fd5c1246311e62a6ffaeb3bd0 +typert.zh.md: 18c24018f4abd644cf35185c2bd06b6980195481 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index 1ff0fe80e4..a61ed85878 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -2,7 +2,7 @@ English | [中文](typert.zh.md) -Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts). +Types shared by generated Remote artifacts, the Host Gateway, and consumer API assemblies. The [TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) owns the architecture and transport decisions; this page records the literal public contracts from [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) and [`dsh-api-gateway`](../../packages/api/gateway/src/types.ts). ## Lookup and Context declarations @@ -126,7 +126,7 @@ interface TypeRTService { } ``` -Generated consumer declarations merge direct namespaces into the map inherited by `ClientApi`. +Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientApi`. ```ts type-equiv /** Merge-extensible direct namespace surface generated for Client API services. */ @@ -191,8 +191,8 @@ interface TypertGateway { `ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. ```ts type-equiv -/** Typed API service augmented by generated direct Remote namespaces. */ -interface ClientApi extends TypeRTRemoteNamespaceMap { +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 3cc0aa2640..18c24018f4 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -2,7 +2,7 @@ [English](typert.md) | 中文 -以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-host-api-gateway`](../../packages/host/api-gateway/src/types.ts) 中公共契约的字面定义。 +以下类型由生成的 Remote 产物、Host Gateway 与消费方 API assembly 共用。[TypeRT Gateway Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) 负责架构与传输决策;本页记录 [`dsh-type-meta`](../../packages/typert/type-meta/src/types.ts) 和 [`dsh-api-gateway`](../../packages/api/gateway/src/types.ts) 中公共契约的字面定义。 ## Lookup 与 Context 声明 @@ -126,7 +126,7 @@ interface TypeRTService { } ``` -生成的消费方声明会把 direct namespace 合并到 `ClientApi` 继承的 map 中。 +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientApi` 继承的 map 中。 ```ts type-equiv /** Merge-extensible direct namespace surface generated for Client API services. */ @@ -191,8 +191,8 @@ interface TypertGateway { `ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 ```ts type-equiv -/** Typed API service augmented by generated direct Remote namespaces. */ -interface ClientApi extends TypeRTRemoteNamespaceMap { +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ea336b1f0..b0809af72e 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: d480f548dd24ea81d132e4b4c0cc364ce1b0cd53 -development.zh.md: 08ef7fd2d3da7db83eb3ca4dff9f9c85f6d7cb5e +development.md: f832956c4c7cbde96613a69db6c636a2246786a7 +development.zh.md: 3ae70e7135ad5faee0e37d99f55cdb41373aab2c diff --git a/docs/development.md b/docs/development.md index d480f548dd..f832956c4c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge 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). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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. +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index 08ef7fd2d3..3ae70e7135 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 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)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 34f4d37ffd..92bf908613 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | -| `internal/service` | - | `api-gateway` | +| `internal/service` | - | `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/docs/module-graph.md b/docs/module-graph.md index ac46e968b3..43923e0865 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -146,6 +146,10 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_api["packages/api"] + pkg_api_gateway["api-gateway"] + pkg_api_remotes["api-remotes"] + end subgraph group_bundle["packages/bundle"] pkg_base["base"] pkg_headless["headless"] @@ -156,7 +160,6 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] - pkg_client_remotes["client-remotes"] pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] @@ -212,7 +215,6 @@ flowchart TD end subgraph group_host["packages/host"] pkg_frontend_static["frontend-static"] - pkg_host_api_gateway["host-api-gateway"] pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] @@ -367,13 +369,13 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_host_api_gateway --> pkg_client_connection - pkg_host_api_gateway --> pkg_invariants - pkg_host_api_gateway --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -614,6 +616,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval +<<<<<<< HEAD <<<<<<< HEAD pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime @@ -630,6 +633,14 @@ flowchart TD pkg_client_remotes --> pkg_host_api_gateway pkg_client_remotes --> pkg_invariants >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= + pkg_api_remotes --> pkg_agent + pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_session + pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_typert_registry +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -783,6 +794,7 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction +<<<<<<< HEAD <<<<<<< HEAD pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale @@ -815,6 +827,9 @@ flowchart TD pkg_client_ui_skill --> pkg_invariants ======= pkg_client_runtime --> pkg_client_remotes +======= + pkg_client_runtime --> pkg_api_remotes +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_client_runtime --> pkg_invariants pkg_client_runtime --> pkg_type_meta pkg_client_runtime --> pkg_typert_registry @@ -1132,8 +1147,8 @@ flowchart TD 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_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_remotes pkg_client_ui_goal --> pkg_client_runtime pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives @@ -1231,8 +1246,8 @@ flowchart TD | [`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) | | [`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) | -| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`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) | @@ -1295,11 +1310,15 @@ 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) | <<<<<<< HEAD +<<<<<<< HEAD | [`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) | ======= | [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= +| [`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) | +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`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) | @@ -1326,6 +1345,7 @@ flowchart TD | [`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) | <<<<<<< HEAD +<<<<<<< HEAD | [`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` | [`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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | @@ -1333,6 +1353,9 @@ flowchart TD ======= | [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | >>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) +======= +| [`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) | +>>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`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) | @@ -1383,7 +1406,7 @@ flowchart TD | [`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-goal`](../packages/client/ui-goal) | `client` | [`client-locale`](../packages/client/locale), [`client-remotes`](../packages/client/remotes), [`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-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-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) | diff --git a/knip.json b/knip.json index 3ce9a32d99..3d7836104f 100644 --- a/knip.json +++ b/knip.json @@ -115,7 +115,7 @@ "tests/**/*.ts" ] }, - "packages/client/remotes": { + "packages/api/remotes": { "entry": [ "tests/**/*.e2e.ts" ], diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index d10f476f79..8aa9b92b91 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 8fbb6069a784a5bd45423a4e1ae11834a597750d -README.zh.md: 42a8d691344c716021188df6fd870a841d543f36 +README.md: 229feae568ba6e40a9c633696097eff46fd5bc95 +README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3 diff --git a/packages/README.md b/packages/README.md index 8fbb6069a7..229feae568 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | +| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface | | [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface | | [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface | | [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 42a8d69134..b84aef020a 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -11,6 +11,7 @@ | 组 | 职责 | 发布预期 | |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | +| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定表面 | | [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 | | [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 | diff --git a/packages/client/remotes/README.i18n.yaml b/packages/api/README.i18n.yaml similarity index 56% rename from packages/client/remotes/README.i18n.yaml rename to packages/api/README.i18n.yaml index 86f2aded18..855eeb8eaa 100644 --- a/packages/client/remotes/README.i18n.yaml +++ b/packages/api/README.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 packages/client/remotes/README.md -README.md: e29188b8e3ae5ecefe194f1355558e9bdeaae7dd -README.zh.md: e6425ab190a28e0a38c3713c4e21645789a8f00c +# pnpm run verify-translation-pairing --write packages/api/README.md +README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df +README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94 diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 0000000000..0dcded5922 --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,17 @@ +# api/ — Remote API layers + +English | [中文](README.zh.md) + +The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments. + +| Package | Role | ctx key | +|---|---|---| +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` | +| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` | + +The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. + +## Known Limitations and Deferred Work + +- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts. +- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md new file mode 100644 index 0000000000..1b9bb9133a --- /dev/null +++ b/packages/api/README.zh.md @@ -0,0 +1,17 @@ +# api/:Remote API 层 + +[English](README.md) | 中文 + +面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。 + +| 包 | 职责 | ctx key | +|---|---|---| +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` | + +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 + +## 已知限制与延期工作 + +- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务契约。 +- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。 diff --git a/packages/host/api-gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml similarity index 56% rename from packages/host/api-gateway/README.i18n.yaml rename to packages/api/gateway/README.i18n.yaml index 8d8d699c7a..41bbb0621f 100644 --- a/packages/host/api-gateway/README.i18n.yaml +++ b/packages/api/gateway/README.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 packages/host/api-gateway/README.md -README.md: eb48c29628d39e381235b1f72754eb114960b1ad -README.zh.md: e53bb6c216e42fe2e970bf2cb80eac9ea7426497 +# pnpm run verify-translation-pairing --write packages/api/gateway/README.md +README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261 +README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca diff --git a/packages/host/api-gateway/README.md b/packages/api/gateway/README.md similarity index 86% rename from packages/host/api-gateway/README.md rename to packages/api/gateway/README.md index eb48c29628..9e3d4d8978 100644 --- a/packages/host/api-gateway/README.md +++ b/packages/api/gateway/README.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-host-api-gateway +# @deepseek-ai/dsh-api-gateway English | [中文](README.zh.md) -Two-sided Remote control for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-host-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave transport, request correlation, trust, and response envelopes to Connection. +Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -20,7 +20,7 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. +Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. ## Model Experience diff --git a/packages/host/api-gateway/README.zh.md b/packages/api/gateway/README.zh.md similarity index 86% rename from packages/host/api-gateway/README.zh.md rename to packages/api/gateway/README.zh.md index e53bb6c216..9bbd46c711 100644 --- a/packages/host/api-gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-host-api-gateway +# @deepseek-ai/dsh-api-gateway [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 Remote 控制。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-host-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将传输、请求关联、信任和响应封装交由 Connection 处理。 +为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -20,7 +20,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 +生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 diff --git a/packages/host/api-gateway/package.json b/packages/api/gateway/package.json similarity index 92% rename from packages/host/api-gateway/package.json rename to packages/api/gateway/package.json index 794ae323aa..fa351d84bf 100644 --- a/packages/host/api-gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-host-api-gateway", - "description": "Host dispatcher and Client API for TypeRT Remote invocations", + "name": "@deepseek-ai/dsh-api-gateway", + "description": "TypeRT Remote Host dispatcher and Client API endpoint", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts similarity index 96% rename from packages/host/api-gateway/src/client/index.ts rename to packages/api/gateway/src/client/index.ts index 5503fc3dcf..bafffa80f7 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -9,10 +9,9 @@ import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypeRTClientApi, TypeRTCodec, - TypeRTDisposer, TypeRTRemoteContribution, - TypeRTRemoteNamespaceMap, } from '@deepseek-ai/dsh-type-meta' type RemoteMethod = (...args: unknown[]) => Promise @@ -40,14 +39,7 @@ interface ScopedProjection { } /** Typed API service augmented by generated direct Remote namespaces. */ -export interface ClientApi extends TypeRTRemoteNamespaceMap { - /** - * Mount one generated Host-for-Client contribution in the caller's fiber. - * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. - */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer -} +export type ClientApi = TypeRTClientApi declare module 'cordis' { interface Context { @@ -67,7 +59,7 @@ export function apply(ctx: Context): void { new ClientApiService(ctx) } -class ClientApiService extends Service implements ClientApi { +class ClientApiService extends Service implements TypeRTClientApi { private readonly ownerCtx: Context private readonly direct = new Map() private readonly scoped = new Map() @@ -77,7 +69,7 @@ class ClientApiService extends Service implements ClientApi { this.ownerCtx = ctx } - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer { + mount(contribution: TypeRTRemoteContribution): ReturnType { this.validateContribution(contribution) const callerCtx = this.ctx const disposeRemote = callerCtx.typert.remotes.register(contribution) diff --git a/packages/host/api-gateway/src/index.ts b/packages/api/gateway/src/index.ts similarity index 99% rename from packages/host/api-gateway/src/index.ts rename to packages/api/gateway/src/index.ts index 8ea26b5990..13cf460f4d 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1,7 +1,7 @@ /** * Live TypeRT Remote dispatch over Cordis Services and registered providers. * Transport, request correlation, and response envelopes belong to Connection. - * @module @deepseek-ai/dsh-host-api-gateway + * @module @deepseek-ai/dsh-api-gateway */ import { Context, Service, symbols } from 'cordis' diff --git a/packages/host/api-gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts similarity index 77% rename from packages/host/api-gateway/src/invariant.ts rename to packages/api/gateway/src/invariant.ts index 65c94b4ac4..711c4edab5 100644 --- a/packages/host/api-gateway/src/invariant.ts +++ b/packages/api/gateway/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-host-api-gateway`. - * @module @deepseek-ai/dsh-host-api-gateway/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`. + * @module @deepseek-ai/dsh-api-gateway/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-host-api-gateway' +const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway' /** Cordis companion plugin name. */ -export const name = 'host-api-gateway-invariant' +export const name = 'api-gateway-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/host/api-gateway/src/types.ts b/packages/api/gateway/src/types.ts similarity index 97% rename from packages/host/api-gateway/src/types.ts rename to packages/api/gateway/src/types.ts index f4bb276c22..0917ba2ca6 100644 --- a/packages/host/api-gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -1,6 +1,6 @@ /** * Carrier-independent TypeRT Gateway request, service, and error contracts. - * @module @deepseek-ai/dsh-host-api-gateway/types + * @module @deepseek-ai/dsh-api-gateway/types */ /** One Remote method request after a carrier has decoded its envelope. */ diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts similarity index 100% rename from packages/host/api-gateway/tests/client.spec.ts rename to packages/api/gateway/tests/client.spec.ts diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts similarity index 99% rename from packages/host/api-gateway/tests/gateway.spec.ts rename to packages/api/gateway/tests/gateway.spec.ts index 0871dc2761..d784a1ac2f 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -16,7 +16,7 @@ import { type TypeRTLookupProvider, } from '@deepseek-ai/dsh-type-meta' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' -import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway' +import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' interface FixtureAgent { readonly id: string diff --git a/packages/host/api-gateway/tsconfig.json b/packages/api/gateway/tsconfig.json similarity index 100% rename from packages/host/api-gateway/tsconfig.json rename to packages/api/gateway/tsconfig.json diff --git a/packages/api/gateway/tsdown.config.ts b/packages/api/gateway/tsdown.config.ts new file mode 100644 index 0000000000..f9049b6067 --- /dev/null +++ b/packages/api/gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml new file mode 100644 index 0000000000..c3c13a8049 --- /dev/null +++ b/packages/api/remotes/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/api/remotes/README.md +README.md: cf54a56a849246d4efdca09cadd42e157064bdee +README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md new file mode 100644 index 0000000000..cf54a56a84 --- /dev/null +++ b/packages/api/remotes/README.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-api-remotes + +English | [中文](README.zh.md) + +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. + +`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. + +The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway. + +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.api` contract. + +## Model Experience + +None, as this BFF selects Remote application methods and identity policy but registers no model surface. + +#### KV Cache effect + +No direct effect; mounted Host capabilities own any model-visible behavior they trigger. + +## Known Limitations and Deferred Work + +- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. +- Additional capabilities require an explicit `/remote` value import and mount in this assembly. +- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md new file mode 100644 index 0000000000..5cd7ef21c9 --- /dev/null +++ b/packages/api/remotes/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-api-remotes + +[English](README.md) | 中文 + +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 + +`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 + +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。 + +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。 + +## 模型体验 + +无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 + +#### KV Cache 影响 + +无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 + +## 已知限制与暂缓事项 + +- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 +- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 +- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。 diff --git a/packages/client/remotes/package.json b/packages/api/remotes/package.json similarity index 64% rename from packages/client/remotes/package.json rename to packages/api/remotes/package.json index ba4e7b6a01..0a1e3ec71d 100644 --- a/packages/client/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-remotes", - "description": "Platform-neutral assembly of explicitly selected Host Remote contributions", + "name": "@deepseek-ai/dsh-api-remotes", + "description": "Remote BFF assembly and Host Agent/Session lookup policy", "version": "0.0.1", "private": true, "type": "module", @@ -24,7 +24,7 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-host-api-gateway" + "@deepseek-ai/dsh-api-gateway" ], "platform": "web", "immediately": true @@ -40,16 +40,25 @@ "lib/client.js", "lib/types/**/*.d.ts" ], + "dependencies": { + "@deepseek-ai/dsh-type-meta": "workspace:^" + }, "peerDependencies": { - "@deepseek-ai/dsh-host-api-gateway": "^0.0.1", + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-typert-registry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts new file mode 100644 index 0000000000..e3a5b27df8 --- /dev/null +++ b/packages/api/remotes/src/agent-lookup.ts @@ -0,0 +1,193 @@ +/** Host BFF policy for resolving Remote Agent and Session identities. */ + +import type { Context } from 'cordis' +import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import type {} from '@deepseek-ai/dsh-typert-registry' + +/** Caller-facing failures preserved by the Gateway's RPC adapter. */ +export type ApiRemoteLookupError = + | { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } } + | { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } } + | { readonly code: 'internal'; readonly message: string; readonly details: Record } + +/** Result of resolving one session identity to its live Agent. */ +export type ApiRemoteAgentResult = + | { readonly agent: Agent } + | { readonly error: ApiRemoteLookupError } + +/** Resume configuration supplied by the owning Host composition. */ +export interface ApiRemoteAgentOptions { + /** Per-Agent defaults used when a cold identity must resume. */ + readonly agentOptions?: AgentOptions + /** Host-specific Agent-scope composition completed before publication. */ + readonly setup?: AgentSetup +} + +/** Cold identity absent from the durable session store. */ +export class ApiRemoteSessionNotFound extends Error {} + +/** Session identity whose lifecycle belongs to subagent routing. */ +export class ApiRemoteSubagentSessionOwnership extends Error { + /** + * Construct the ownership fence. + * @param sessionId - identity reserved to subagent routing. + */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`) + } +} + +/** + * Test whether generic Host routing must leave an identity to subagent routing. + * @param ctx - Host Context carrying the live Agent registry. + * @param session - attached or live Session metadata. + * @param agent - live Agent when one is registered. + * @returns whether generic Remote and legacy API calls must reject the identity. + */ +export function hasApiRemoteSubagentOwner( + ctx: Context, + session: Pick, + agent: Agent | undefined, +): boolean { + if (session.header.origin === 'subagent') return true + const parentId = session.header.parentSession + if (parentId === undefined || agent === undefined) return false + const parent = ctx.agents.get(parentId) + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) +} + +/** + * Build the stable caller-facing ownership rejection. + * @param sessionId - identity reserved to subagent routing. + * @returns the existing `agent-busy` RPC shape. + */ +export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError { + return { + code: 'agent-busy', + message: `session "${sessionId}" is owned by subagent routing`, + details: { reason: 'use subagent delivery for this child session' }, + } +} + +/** + * Inspect one cold served session without repairing, resuming, or publishing it. + * @param ctx - Host Context carrying the optional persistence provider. + * @param sessionId - durable identity to inspect. + * @returns detached metadata and events for a servable session. + * @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session. + */ +export async function inspectApiRemoteSession( + ctx: Context, + sessionId: SessionId, +): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) { + throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') + } + const meta = (await persistence.list()).find(candidate => candidate.id === sessionId) + if (meta === undefined || meta.cwd === undefined) { + throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) + } + const inspected = await persistence.inspect(sessionId) + if (inspected.meta.cwd === undefined) { + throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) + } + return { meta: inspected.meta, events: [...inspected.events] } +} + +/** + * Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups. + * Live Agents are reused, ordinary cold sessions resume once per identity, and + * subagent-owned identities retain the legacy `agent-busy` fence. + * @param ctx - owning Host Context. + * @param options - defaults and Agent-scope setup used only for cold resume. + * @returns resolver shared by legacy API Proxy methods and TypeRT lookups. + */ +export function createApiRemoteAgentResolver( + ctx: Context, + options: ApiRemoteAgentOptions, +): (sessionId: SessionId) => Promise { + const resumes = new Map>() + + const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => { + const live = ctx.agents.get(sessionId) + if (live === undefined) return undefined + if (hasApiRemoteSubagentOwner(ctx, live.session, live)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + return { agent: live } + } + + const agentFor = async (sessionId: SessionId): Promise => { + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + let resume = resumes.get(sessionId) + if (resume === undefined) { + resume = (async () => { + try { + const inspected = await inspectApiRemoteSession(ctx, sessionId) + if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) { + throw new ApiRemoteSubagentSessionOwnership(sessionId) + } + const publishedSession = ctx.sessions.get(sessionId) + const publishedAgent = ctx.agents.get(sessionId) + if (publishedSession !== undefined + && hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) { + throw new ApiRemoteSubagentSessionOwnership(sessionId) + } + const handle = await ctx.agents.resume({ + resumeSessionId: sessionId, + ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions }, + ...options.setup === undefined ? {} : { setup: options.setup }, + }) + return handle.agent + } finally { + resumes.delete(sessionId) + } + })() + resumes.set(sessionId, resume) + } + try { + return { agent: await resume } + } catch (error: unknown) { + if (error instanceof ApiRemoteSessionNotFound) { + return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } + } + if (error instanceof ApiRemoteSubagentSessionOwnership) { + return { error: apiRemoteSubagentOwnershipError(error.sessionId) } + } + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { + return { error: apiRemoteSubagentOwnershipError(sessionId) } + } + return { + error: { + code: 'internal', + message: `resume failed for session "${sessionId}": ${String(error)}`, + details: {}, + }, + } + } + } + + ctx.inject(['typert'], (typeCtx) => { + const resolveAgent = async (sessionId: SessionId): Promise => { + const found = await agentFor(sessionId) + if ('error' in found) throw new TypeRTLookupFailure(found.error) + return found.agent + } + typeCtx.typert.lookups.configure('agent', resolveAgent) + typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + }) + + return agentFor +} diff --git a/packages/client/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts similarity index 64% rename from packages/client/remotes/src/client/index.ts rename to packages/api/remotes/src/client/index.ts index 09757b5e9e..1bc36b62ee 100644 --- a/packages/client/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,12 +1,19 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-host-api-gateway/client' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta' -export type { ClientApi } from '@deepseek-ai/dsh-host-api-gateway/client' +export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' +declare module 'cordis' { + interface Context { + /** Generated direct Remote namespaces selected by this Client assembly. */ + api: TypeRTClientApi + } +} + /** Required service: the typed Client API contribution mount. */ export const inject = ['api'] diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts new file mode 100644 index 0000000000..4cd70f4a78 --- /dev/null +++ b/packages/api/remotes/src/index.ts @@ -0,0 +1,18 @@ +/** Host BFF entry and Loader shell for the Remote contribution assembly. */ + +export { + ApiRemoteSessionNotFound, + ApiRemoteSubagentSessionOwnership, + apiRemoteSubagentOwnershipError, + createApiRemoteAgentResolver, + hasApiRemoteSubagentOwner, + inspectApiRemoteSession, +} from './agent-lookup.ts' +export type { + ApiRemoteAgentOptions, + ApiRemoteAgentResult, + ApiRemoteLookupError, +} from './agent-lookup.ts' + +/** Host plugin body; the selected contributions mount only in Client environments. */ +export function apply(): void {} diff --git a/packages/client/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts similarity index 70% rename from packages/client/remotes/src/invariant.ts rename to packages/api/remotes/src/invariant.ts index 1a6b0ba237..3310bed11f 100644 --- a/packages/client/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -1,17 +1,17 @@ -/** Package-owned invariant companion for `@deepseek-ai/dsh-client-remotes`. */ +/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-remotes' +const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' /** Cordis companion plugin name. */ -export const name = 'client-remotes-invariant' +export const name = 'api-remotes-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** No runtime invariant: the API service owns contribution and method lifecycle atomically. */ +/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/client/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts similarity index 95% rename from packages/client/remotes/tests/built-lib.e2e.ts rename to packages/api/remotes/tests/built-lib.e2e.ts index 0cee3eb245..b8f6c81e98 100644 --- a/packages/client/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -17,13 +17,13 @@ const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href const requiredArtifacts = [ 'packages/client/connection/lib/client.js', 'packages/client/connection/lib/index.js', - 'packages/client/remotes/lib/client.js', + 'packages/api/remotes/lib/client.js', 'packages/core/agent/lib/index.js', 'packages/core/session/lib/index.js', 'packages/goal/goal/lib/index.js', 'packages/goal/goal/lib/typert.host.js', - 'packages/host/api-gateway/lib/client.js', - 'packages/host/api-gateway/lib/index.js', + 'packages/api/gateway/lib/client.js', + 'packages/api/gateway/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', ].every(path => existsSync(artifact(path))) @@ -32,15 +32,15 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => { const urls = Object.fromEntries(Object.entries({ agent: 'packages/core/agent/lib/index.js', - apiGatewayClient: 'packages/host/api-gateway/lib/client.js', - apiGatewayHost: 'packages/host/api-gateway/lib/index.js', + apiGatewayClient: 'packages/api/gateway/lib/client.js', + apiGatewayHost: 'packages/api/gateway/lib/index.js', connectionClient: 'packages/client/connection/lib/client.js', connectionHost: 'packages/client/connection/lib/index.js', goal: 'packages/goal/goal/lib/index.js', goalTypert: 'packages/goal/goal/lib/typert.host.js', registryClient: 'packages/typert/registry/lib/client.js', registryHost: 'packages/typert/registry/lib/index.js', - remotesClient: 'packages/client/remotes/lib/client.js', + remotesClient: 'packages/api/remotes/lib/client.js', session: 'packages/core/session/lib/index.js', }).map(([key, path]) => [key, artifactUrl(path)])) const script = ` @@ -131,8 +131,8 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { for (const id of [ '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-host-api-gateway', - '@deepseek-ai/dsh-client-remotes', + '@deepseek-ai/dsh-api-gateway', + '@deepseek-ai/dsh-api-remotes', ]) { const plugin = instantiate(id) await client.plugin({ inject: plugin.inject, apply: plugin.apply }) diff --git a/packages/client/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json similarity index 63% rename from packages/client/remotes/tsconfig.json rename to packages/api/remotes/tsconfig.json index c99a5fce19..148804dc0f 100644 --- a/packages/client/remotes/tsconfig.json +++ b/packages/api/remotes/tsconfig.json @@ -12,7 +12,19 @@ "path": "../../../vendor/cordis" }, { - "path": "../../host/api-gateway" + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../typert/registry" }, { "path": "../../ui/commands" diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts new file mode 100644 index 0000000000..287b2c7975 --- /dev/null +++ b/packages/api/remotes/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 0b1cc43a50..11b23c27be 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -41,7 +41,7 @@ name: '@deepseek-ai/dsh-typert-loader' - id: typert-gateway - name: '@deepseek-ai/dsh-host-api-gateway' + name: '@deepseek-ai/dsh-api-gateway' - id: session-title name: '@deepseek-ai/dsh-session-title' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 2ec17d9c66..9895d8834c 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-host-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 001c43948d..dc3212a36c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -124,8 +124,8 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' - - id: client-remotes - name: '@deepseek-ai/dsh-client-remotes' + - id: api-remotes + name: '@deepseek-ai/dsh-api-remotes' - id: client-runtime name: '@deepseek-ai/dsh-client-runtime' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 89b5e8e2a7..4e2d9cf70b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/remotes/README.md b/packages/client/remotes/README.md deleted file mode 100644 index e29188b8e3..0000000000 --- a/packages/client/remotes/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# @deepseek-ai/dsh-client-remotes - -English | [中文](README.zh.md) - -Platform-neutral Client facade for Host Remote capabilities selected by this application. Its Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Host API Gateway or individual Remote runtime entries. - -The current assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while the Client face of `@deepseek-ai/dsh-host-api-gateway` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. - -This package contains no transport or Host discovery logic. It can be reused by Web or a future TUI Client that provides the same React-free `ctx.api` contract. - -## Model Experience - -None, as this Client assembly selects Remote application methods and registers no model surface. - -#### KV Cache effect - -No direct effect; mounted Host capabilities own any model-visible behavior they trigger. - -## Known Limitations and Deferred Work - -- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. -- Additional capabilities require an explicit `/remote` value import and mount in this assembly. diff --git a/packages/client/remotes/README.zh.md b/packages/client/remotes/README.zh.md deleted file mode 100644 index e6425ab190..0000000000 --- a/packages/client/remotes/README.zh.md +++ /dev/null @@ -1,22 +0,0 @@ -# @deepseek-ai/dsh-client-remotes - -[English](README.md) | 中文 - -为本应用选定的 Host Remote 能力提供平台无关的 Client 外观。其 Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖此外观,而不依赖 Host API Gateway 或单独的 Remote 运行时入口。 - -当前组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-host-api-gateway` 的 Client 侧负责描述符校验、具体的根级方法和作用域方法、调用与取消。 - -本包不包含传输逻辑或 Host 发现逻辑。Web 和未来的 TUI Client 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用本包。 - -## 模型体验 - -无,因为此 Client 组合只选择应用的 Remote 方法,不注册任何模型接口。 - -#### KV Cache 影响 - -无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。 - -## 已知限制与暂缓事项 - -- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 -- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 diff --git a/packages/client/remotes/src/index.ts b/packages/client/remotes/src/index.ts deleted file mode 100644 index c8c4ff20be..0000000000 --- a/packages/client/remotes/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host Loader entry for the Client Remote contribution assembly. */ - -/** Host plugin body; the selected contributions mount only in Client environments. */ -export function apply(): void {} diff --git a/packages/client/remotes/tsdown.config.ts b/packages/client/remotes/tsdown.config.ts deleted file mode 100644 index 20fa098462..0000000000 --- a/packages/client/remotes/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index cc51aa772d..711510b705 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -25,7 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-typert-registry" ], "platform": "web", @@ -49,14 +49,14 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-client-remotes": "^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-client-remotes": "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 f1efd6a65d..a9d2bb0d7d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,7 +1,7 @@ /** 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-client-remotes/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' diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 85ba61d41a..efbf7c26d7 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -21,7 +21,7 @@ "path": "../connection" }, { - "path": "../remotes" + "path": "../../api/remotes" }, { "path": "../../host/apiproxy" diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 4c26405bd8..63410707a0 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -25,7 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-remotes", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -38,7 +38,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-remotes": "^0.0.1", + "@deepseek-ai/dsh-api-remotes": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -50,7 +50,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 8fcfd292d2..2e49d5b6b8 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -10,7 +10,7 @@ */ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. -import type {} from '@deepseek-ai/dsh-client-remotes/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 2bb4070b18..263dfceb26 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -15,7 +15,7 @@ "path": "../locale" }, { - "path": "../remotes" + "path": "../../api/remotes" }, { "path": "../runtime" diff --git a/packages/host/api-gateway/tsdown.config.ts b/packages/host/api-gateway/tsdown.config.ts deleted file mode 100644 index 1f95a1f2c5..0000000000 --- a/packages/host/api-gateway/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../../client/tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-host-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index ce740a025f..91d03b4448 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -39,6 +39,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -56,8 +57,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-type-meta": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", @@ -71,6 +70,8 @@ "devDependencies": { "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", "cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "workspace:^" } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f2c199feca..e4b715a0c4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -19,9 +19,6 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' -import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' -// Type-only: resolves the optional `ctx.typert` lookup-policy composition. -import type {} from '@deepseek-ai/dsh-typert-registry' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, @@ -72,6 +69,14 @@ import type { } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import { + ApiRemoteSessionNotFound as SessionNotFound, + ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership, + apiRemoteSubagentOwnershipError, + createApiRemoteAgentResolver, + hasApiRemoteSubagentOwner, + inspectApiRemoteSession, +} from '@deepseek-ai/dsh-api-remotes' import { openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ @@ -666,19 +671,6 @@ async function catalogChild( } } -/** - * Thrown by the cold-resume path when the id names no servable session - * (absent from the store, or a pre-project legacy log without a cwd). - */ -class SessionNotFound extends Error {} - -/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */ -class SubagentSessionOwnership extends Error { - constructor(readonly sessionId: SessionId) { - super(`session "${sessionId}" is a subagent session; use subagent delivery`) - } -} - /** Requested identity already belongs to a session with another project cwd. */ class SessionCwdConflict extends Error { constructor( @@ -752,8 +744,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget } const targets = new WeakMap() - /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ - const resumes = new Map>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ const sessionCreations = new Map>() /** Serializes path ownership and explicit title checks with Workspace mutations. */ @@ -811,6 +801,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro targetFor(agent) } + const hasSubagentOwner = ( + session: Pick, + agent: Agent | undefined, + ): boolean => hasApiRemoteSubagentOwner(ctx, session, agent) + const subagentOwnershipError = (sessionId: SessionId): RpcError => + apiRemoteSubagentOwnershipError(sessionId) + const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => + inspectApiRemoteSession(ctx, sessionId) + const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installTarget }) + /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { const envelope = frame(payload) @@ -992,131 +992,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } - /** - * Generic Host interaction cannot claim a durably classified subagent - * (`origin: 'subagent'` in the header) or an Agent runtime-owned by its - * live parent. - */ - function hasSubagentOwner( - session: Pick, - agent: Agent | undefined, - ): boolean { - if (session.header.origin === 'subagent') return true - const parentId = session.header.parentSession - if (parentId === undefined || agent === undefined) return false - const parent = ctx.agents.get(parentId) - return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) - } - - /** Stable generic-Host error for an identity reserved to subagent routing. */ - function subagentOwnershipError(sessionId: SessionId): RpcError { - return { - code: 'agent-busy', - message: `session "${sessionId}" is owned by subagent routing`, - details: { reason: 'use subagent delivery for this child session' }, - } - } - - /** Inspect one cold served session without repairing, resuming, or publishing it. */ - async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') - } - const meta = (await persistence.list()).find(m => m.id === sessionId) - if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - const inspected = await persistence.inspect(sessionId) - if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`) - return { meta: inspected.meta, events: [...inspected.events] } - } - - /** - * Resolve one live registered identity through the subagent-ownership - * fence: subagent-owned agents answer `agent-busy`, plain agents pass. - * Fences the live agent's own session rather than trusting a - * "registered ⇒ attached-store" invariant — a registered subagent whose - * session is ever absent from the attached store must still not be handed - * out through generic Host routing. `undefined` means no live agent. - */ - function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined { - const live = ctx.agents.get(sessionId) - if (live === undefined) return undefined - if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } - return { agent: live } - } - - async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - return { error: subagentOwnershipError(sessionId) } - } - let resume = resumes.get(sessionId) - if (resume === undefined) { - resume = (async () => { - try { - const inspected = await inspectServable(sessionId) - if (hasSubagentOwner({ header: inspected.meta }, undefined)) { - throw new SubagentSessionOwnership(sessionId) - } - const publishedSession = ctx.sessions.get(sessionId) - const publishedAgent = ctx.agents.get(sessionId) - if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) { - throw new SubagentSessionOwnership(sessionId) - } - const handle = await ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: agentOptions(), - setup: installTarget, - }) - return handle.agent - } finally { - resumes.delete(sessionId) - } - })() - resumes.set(sessionId, resume) - } - try { - return { agent: await resume } - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } - } - if (error instanceof SubagentSessionOwnership) { - return { error: subagentOwnershipError(error.sessionId) } - } - // A concurrent publish can win the identity between the pre-resume - // re-check and `ctx.agents.resume` publication; the ID-collision - // rejection falls through here. Mirror ensureSession's `.catch` in - // full: classify a subagent-owned winner into the stable ownership - // error, and hand a clean plain-agent winner straight back. - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - return { error: subagentOwnershipError(sessionId) } - } - // The internal details slot is contractually {}; the reason rides the message. - return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } - } - } - - // Remote object parameters use the same identity policy as API Proxy methods: - // ordinary cold sessions resume once, while subagent-owned identities retain - // their stable caller-facing rejection. The provider packages continue to - // own wire declarations and live-only defaults; this Host composition owns - // the broader lookup policy. - ctx.inject(['typert'], (typeCtx) => { - const resolveAgent = async (sessionId: SessionId): Promise => { - const found = await agentFor(sessionId) - if ('error' in found) throw new TypeRTLookupFailure(found.error) - return found.agent - } - typeCtx.typert.lookups.configure('agent', resolveAgent) - typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) - }) - type SessionReadState = { id: SessionId header: SessionHeader diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 23c170f4fd..912f2cd794 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../api/remotes" + }, { "path": "../../util/brand" }, @@ -38,12 +41,6 @@ { "path": "../../core/tools" }, - { - "path": "../../typert/type-meta" - }, - { - "path": "../../typert/registry" - }, { "path": "../../session-persistence/session-persistence" }, diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 7ded29fa4a..1418c9d7f2 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,6 +41,7 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + TypeRTClientApi, TypeRTClientContextBinder, TypeRTCodec, TypeRTContext, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 7831c08e37..b65690115f 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -176,6 +176,16 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } +/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ +export interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { + /** + * Mount one generated Host-for-Client contribution in the caller's fiber. + * @param contribution - explicitly selected Remote package artifact. + * @returns disposer withdrawing descriptors and concrete methods together. + */ + mount(contribution: TypeRTRemoteContribution): TypeRTDisposer +} + /** * Resolve one validated wire identity, synchronously or asynchronously. * @param id - validated wire identity. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0adfb22c1..bd4ff1ea14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,59 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/api/gateway: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + zod: + specifier: ^4.4.3 + version: 4.4.3 + + packages/api/remotes: + dependencies: + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-invariants': @@ -877,6 +930,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env @@ -916,9 +972,6 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../goal/goal-session - '@deepseek-ai/dsh-host-api-gateway': - specifier: workspace:^ - version: link:../../host/api-gateway '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1119,6 +1172,9 @@ importers: packages/bundle/web-app: dependencies: + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -1131,9 +1187,6 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-remotes': - specifier: workspace:^ - version: link:../../client/remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -1348,21 +1401,6 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/client/remotes: - devDependencies: - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../goal/goal - '@deepseek-ai/dsh-host-api-gateway': - specifier: workspace:^ - version: link:../../host/api-gateway - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - packages/client/runtime: dependencies: '@deepseek-ai/dsh-client-connection': @@ -1405,9 +1443,9 @@ 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-client-remotes': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../remotes + version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1617,12 +1655,12 @@ importers: packages/client/ui-goal: devDependencies: + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-remotes': - specifier: workspace:^ - version: link:../remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -3757,36 +3795,14 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/host/api-gateway: - dependencies: - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../../typert/type-meta - devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../client/connection - '@deepseek-ai/dsh-host-webserver': - specifier: workspace:^ - version: link:../webserver - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - zod: - specifier: ^4.4.3 - version: 4.4.3 - packages/host/apiproxy: dependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -3838,12 +3854,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-type-meta': - specifier: workspace:^ - version: link:../../typert/type-meta - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../ui/user-approval @@ -3869,6 +3879,12 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 088329e83d..54581a8605 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -289,7 +289,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', - InvokeRemoteRequest: 'gateway invocation contract is owned by packages/host/api-gateway/README.md', + InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md', PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md', KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 6d6a76e476..9adc3d9768 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -601,7 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', - 'packages/client/remotes/tests/built-lib.e2e.ts', + 'packages/api/remotes/tests/built-lib.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index edadc3f134..ecb13f167c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1538,22 +1538,22 @@ { "doc": "docs/core-data-structures/typert.md", "symbol": "InvokeRemoteRequest", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", "symbol": "TypertGatewayErrorCode", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", "symbol": "TypertGateway", - "source": "packages/host/api-gateway/src/types.ts" + "source": "packages/api/gateway/src/types.ts" }, { "doc": "docs/core-data-structures/typert.md", - "symbol": "ClientApi", - "source": "packages/host/api-gateway/src/client/index.ts" + "symbol": "TypeRTClientApi", + "source": "packages/typert/type-meta/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 78745dbed1..e131d6c403 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,7 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/remotes': { kind: 'none', reason: 'Client-side Remote assembly; selected business methods own any model-visible effect.' }, + 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' }, 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, @@ -126,7 +126,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, - 'packages/host/api-gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, 'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ce4fca35f9..b9907348fa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,10 +41,10 @@ "@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-registry/client": ["./packages/typert/registry/src/client/index.ts"], - "@deepseek-ai/dsh-host-api-gateway": ["./packages/host/api-gateway/src/index.ts"], - "@deepseek-ai/dsh-host-api-gateway/client": ["./packages/host/api-gateway/src/client/index.ts"], - "@deepseek-ai/dsh-host-api-gateway/invariant": ["./packages/host/api-gateway/src/invariant.ts"], - "@deepseek-ai/dsh-host-api-gateway/types": ["./packages/host/api-gateway/src/types.ts"], + "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"], + "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], + "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], + "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], "@deepseek-ai/dsh-type-meta": ["./packages/typert/type-meta/src/index.ts"], "@deepseek-ai/dsh-type-meta/types": ["./packages/typert/type-meta/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], @@ -151,8 +151,8 @@ "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], - "@deepseek-ai/dsh-client-remotes": ["./packages/client/remotes/src"], - "@deepseek-ai/dsh-client-remotes/client": ["./packages/client/remotes/src/client/index.ts"], + "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], + "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 327b337963..9821c0e41b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -52,8 +52,8 @@ { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/host/api-gateway" }, - { "path": "./packages/client/remotes" }, + { "path": "./packages/api/gateway" }, + { "path": "./packages/api/remotes" }, { "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 37c20c0d5c..6884839536 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -102,7 +102,7 @@ { "path": "./packages/core/scope" }, { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, - { "path": "./packages/host/api-gateway" }, + { "path": "./packages/api/gateway" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/vitest.config.ts b/vitest.config.ts index 56a5a1575b..f5a86ac7a9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -181,8 +181,8 @@ export default defineConfig({ 'packages/client/connection/src/http-bridge.ts', // This assembly imports generated Host-for-Client code that exists // only in lib; the post-build built-bin smoke executes both entries. - 'packages/client/remotes/src/index.ts', - 'packages/client/remotes/src/client/index.ts', + 'packages/api/remotes/src/index.ts', + 'packages/api/remotes/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/connection/src/client/fixture.ts', From 502bd2b6f736d8baafb115d512589003eba4c41c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:00:35 +0800 Subject: [PATCH 57/88] fix: docs --- docs/module-graph.md | 111 ++++++++----------------------------------- 1 file changed, 19 insertions(+), 92 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 43923e0865..9cf6f4c895 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -421,13 +421,6 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt -<<<<<<< HEAD - 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 -======= ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -616,31 +609,15 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval -<<<<<<< HEAD -<<<<<<< HEAD - 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_client_remotes --> pkg_goal - pkg_client_remotes --> pkg_host_api_gateway - pkg_client_remotes --> pkg_invariants ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) + pkg_command_feedback --> pkg_commands + pkg_command_feedback --> pkg_invariants + pkg_command_feedback --> pkg_session pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -794,46 +771,10 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction -<<<<<<< HEAD -<<<<<<< HEAD - 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_client_connection - 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_client_runtime --> pkg_client_remotes -======= pkg_client_runtime --> pkg_api_remotes ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) pkg_client_runtime --> pkg_invariants pkg_client_runtime --> pkg_type_meta pkg_client_runtime --> pkg_typert_registry ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1104,11 +1045,6 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1147,6 +1083,11 @@ flowchart TD 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 @@ -1163,6 +1104,14 @@ flowchart TD 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 @@ -1262,10 +1211,6 @@ flowchart TD | [`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) | -<<<<<<< HEAD -| [`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) | -======= ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) | [`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) | @@ -1309,16 +1254,8 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`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) | -<<<<<<< HEAD -<<<<<<< HEAD -| [`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) | -======= -| [`client-remotes`](../packages/client/remotes) | `client` | [`goal`](../packages/goal/goal), [`host-api-gateway`](../packages/host/api-gateway), [`invariants`](../packages/support/invariants) | ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= | [`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) | ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`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) | @@ -1344,18 +1281,7 @@ 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) | -<<<<<<< HEAD -<<<<<<< HEAD -| [`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` | [`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), [`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) | -======= -| [`client-runtime`](../packages/client/runtime) | `client` | [`client-remotes`](../packages/client/remotes), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | ->>>>>>> d6e02ebbcc (feat(goal): add TypeRT gateway example) -======= | [`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) | ->>>>>>> c671d9e48c (refactor(api): colocate gateway and remote assembly) | [`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) | @@ -1400,14 +1326,15 @@ flowchart TD | [`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) | -| [`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) | | [`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) | From ef8660076b05ed909065387e139592ffbf79329a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:04:25 +0800 Subject: [PATCH 58/88] fix: docs budget --- scripts/doc-budgets.manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a6ad066add..b5c000a714 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1775, + "AGENTS.md": 1782, "docs/AGENTS.md": 1320, "docs/architecture.md": 2160, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 920 + "packages/README.md": 936 } From d2596a0d74ed1729f687d2f2a224405be7f813a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:07:17 +0800 Subject: [PATCH 59/88] test(api-remotes): cover lookup publication races --- .../api/remotes/tests/agent-lookup.spec.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/api/remotes/tests/agent-lookup.spec.ts diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts new file mode 100644 index 0000000000..c9110b8f3f --- /dev/null +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' + +const sid = (value: string): SessionId => value as SessionId + +function header(id: SessionId): SessionHeader { + return { version: 0, id, createdAt: 1, cwd: '/proj' } +} + +async function createContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +function provideSession( + ctx: Context, + meta: SessionHeader, + inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>, +): void { + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + } as never) +} + +function stubAgent(ctx: Context, session: Session): Agent { + return { id: session.id, session, status: 'idle', ctx } as Agent +} + +describe('API Remote Agent resolver races', () => { + it('maps an inspected session without a cwd to session-not-found', async () => { + const ctx = await createContext() + const sessionId = sid('missing-after-inspect') + const meta = header(sessionId) + provideSession(ctx, meta, () => Promise.resolve({ + meta: { ...meta, cwd: undefined } as unknown as SessionHeader, + events: [], + })) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } }) + await ctx.fiber.dispose() + }) + + it('resumes through a concurrently attached ordinary Session without optional defaults', async () => { + const ctx = await createContext() + const sessionId = sid('ordinary-attach-race') + const meta = header(sessionId) + let published: Session | undefined + provideSession(ctx, meta, () => { + published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) + return Promise.resolve({ meta, events: [] }) + }) + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + if (published === undefined) throw new Error('Session was not published') + return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() } + }) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ agent: { id: sessionId } }) + expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId }) + await ctx.fiber.dispose() + }) + + it('rejects a subagent Session published after durable inspection', async () => { + const ctx = await createContext() + const sessionId = sid('owned-attach-race') + const meta = header(sessionId) + provideSession(ctx, meta, () => { + ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + return Promise.resolve({ meta, events: [] }) + }) + const resume = vi.spyOn(ctx.agents, 'resume') + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'agent-busy' } }) + expect(resume).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('reclassifies failed resumes after a live or attached subagent wins publication', async () => { + for (const winner of ['agent', 'session'] as const) { + const ctx = await createContext() + const sessionId = sid(`owned-${winner}-resume-race`) + const meta = header(sessionId) + provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] })) + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session)) + throw new Error('session id already published') + }) + + const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) + + expect(result).toMatchObject({ error: { code: 'agent-busy' } }) + await ctx.fiber.dispose() + } + }) +}) From d49028ff5daf083dab533fde27b039842d6da879 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:27 +0800 Subject: [PATCH 60/88] fix: docs --- docs/event-producer-consumer.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 92bf908613..9d486c46f6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../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:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../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`) | - | From e89d078819de825aa0fa1f40983daed1b76275e4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:25:00 +0800 Subject: [PATCH 61/88] fix: test 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 e796de8a8a..794cf18f49 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 `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `client-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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 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 `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `client-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、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仓库类型检查会执行全仓 `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` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、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 686ee5b3f6824b4bcdda4e334d59bf55ea0aec3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:53:04 +0800 Subject: [PATCH 62/88] fix(api-gateway): harden remote lifecycle and recovery --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- packages/api/gateway/src/client/index.ts | 20 ++++---- packages/api/gateway/src/index.ts | 9 ++-- packages/api/gateway/tests/client.spec.ts | 14 ++++++ packages/api/gateway/tests/gateway.spec.ts | 16 +++++++ packages/api/remotes/src/agent-lookup.ts | 1 + .../api/remotes/tests/agent-lookup.spec.ts | 44 +++++++++++++++++ packages/client/ui-goal/src/client/index.ts | 14 +++--- .../ui-goal/tests/browser-plugin.spec.tsx | 15 +++++- packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 1 + packages/typert/registry/README.zh.md | 1 + packages/typert/registry/src/service.ts | 48 ++++++++++++++++++- packages/typert/registry/tests/typert.spec.ts | 30 ++++++++++++ packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 +- packages/typert/type-meta/README.zh.md | 2 +- packages/typert/type-meta/src/index.ts | 1 + packages/typert/type-meta/src/types.ts | 18 ++++++- 21 files changed, 218 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 9ba0cf8dc1..1e4aeaabd7 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 -2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a +2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b +2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index c4f3a5b94b..3d5a79fd4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -164,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. -Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. +Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index e11d8ebe42..3d6ec680ba 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -164,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 +业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index bafffa80f7..ddef36e288 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi { for (const method of methods) record.service.assertMethodAvailable(method) } else { for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - if (this.ownerCtx.reflect.props[namespace] !== undefined) { + const property = this.ownerCtx.reflect.props[namespace] + if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) } } @@ -224,6 +225,7 @@ class ClientApiService extends Service implements TypeRTClientApi { if (namespace.tokens.get(descriptor.method) !== token) return namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) + if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) } } @@ -289,7 +291,7 @@ class ScopedRemoteNamespace { private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() - private provided = false + private disposeService: (() => void) | undefined readonly name: string static assertMethodAvailable(namespace: string, method: string): void { @@ -331,12 +333,7 @@ class ScopedRemoteNamespace { }, }) if (activate) { - if (this.provided) { - this.ownerCtx.set(this.name, this) - } else { - this.ownerCtx.reflect.provide(this.name, this) - this.provided = true - } + this.disposeService = this.ownerCtx.reflect.provide(this.name, this) } } catch (error) { Reflect.deleteProperty(this, method) @@ -348,11 +345,14 @@ class ScopedRemoteNamespace { remove(method: string): void { Reflect.deleteProperty(this, method) this.methods.delete(method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined) + if (this.methods.size !== 0) return + const disposeService = this.disposeService + this.disposeService = undefined + disposeService?.() } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 13cf460f4d..5899f5d560 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) - const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) const receiver = receiverContext.get(descriptor.service) as unknown if (!isObject(receiver)) { throw new TypertGatewayError( @@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway { } } - private resolveReceiverContext( + private async resolveReceiverContext( descriptor: InvocationDescriptor, args: Readonly>, endpoint: string, - ): Context { + ): Promise { if (descriptor.invocation.kind === 'direct') return this.ctx const invocation = descriptor.invocation const provider = this.ctx.typert.contexts.getHost(invocation.context) @@ -358,8 +358,9 @@ export class TypertGatewayService extends Service implements TypertGateway { const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) let context: Context | undefined try { - context = provider.resolve(identity) + context = await provider.resolve(identity) } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw cause throw new TypertGatewayError( 'context-failed', endpoint, diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 2fbcbb9280..feae3056c9 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -526,6 +526,20 @@ describe('Client TypeRT API', () => { await retry() }) + it('unregisters an empty scoped namespace so another provider can claim its name', async () => { + const ctx = await bench(vi.fn()) + const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('goals')).toBeDefined() + + await dispose() + + expect(ctx.get('goals')).toBeUndefined() + const replacement = { owner: 'replacement' } + const disposeReplacement = ctx.reflect.provide('goals', replacement) + expect(ctx.get('goals')).toBe(replacement) + await disposeReplacement() + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index d784a1ac2f..d298116b82 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -538,6 +538,22 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('preserves a Host Context policy rejection for the active RPC adapter', async () => { + const { ctx } = await setup() + const rejection = new TypeRTLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } }) + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(ctx.extend()), + resolve: async () => { throw rejection }, + }) + registerStrict(ctx, [renameDescriptor()]) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).rejects.toBe(rejection) + }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { const { ctx } = await setup() registerStrict(ctx, [renameDescriptor()]) diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index e3a5b27df8..eb54ea9b0b 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -187,6 +187,7 @@ export function createApiRemoteAgentResolver( } typeCtx.typert.lookups.configure('agent', resolveAgent) typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx) }) return agentFor diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts index c9110b8f3f..7179f5b2b2 100644 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -5,6 +5,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' const sid = (value: string): SessionId => value as SessionId @@ -14,6 +16,7 @@ function header(id: SessionId): SessionHeader { async function createContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) return ctx @@ -107,4 +110,45 @@ describe('API Remote Agent resolver races', () => { await ctx.fiber.dispose() } }) + + it('uses the shared cold-resume policy for the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-cold-resume') + const meta = header(sessionId) + let published: Session | undefined + provideSession(ctx, meta, () => { + published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) + return Promise.resolve({ meta, events: [] }) + }) + const agentCtx = ctx.extend() + vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + if (published === undefined) throw new Error('Session was not published') + return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() } + }) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx) + await ctx.fiber.dispose() + }) + + it('applies the subagent ownership fence to the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-owned-subagent') + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + ctx.agents.register(stubAgent(ctx.extend(), session)) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + const resolution = provider.resolve(sessionId) + await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + await ctx.fiber.dispose() + }) }) diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2e49d5b6b8..bea4f67df2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -38,10 +38,10 @@ const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ export const inject = ['slots', 'sessions', 'api', 'locale'] -/** Map one generated Remote call onto the strip's inline-render shape. */ -async function settle(result: Promise): Promise { +/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +async function settle(invoke: () => Promise): Promise { try { - await result + await invoke() return { ok: true } } catch (error) { const cause = error instanceof Error ? error.cause : undefined @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.api.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.api.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.api.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 11c95e27d9..f900682712 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -70,7 +70,7 @@ async function bench(options: { resume: answer(`${prefix}/resume`, { ref }), clear: answer(`${prefix}/clear`, ref), }) - let activeGoals = goals('goals') + let activeGoals: ReturnType | undefined = goals('goals') ctx.provide('api', { get goals() { return activeGoals }, }) @@ -95,6 +95,7 @@ async function bench(options: { fiber, calls, remountGoals: () => { activeGoals = goals('remounted-goals') }, + unmountGoals: () => { activeGoals = undefined }, entry: () => { const entry = ctx.slots.entries('conversation.input.dock')[0] if (entry === undefined) return undefined @@ -141,6 +142,18 @@ describe('ui-goal browser plugin', () => { expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) }) + it('settles every verb when the Remote namespace is temporarily absent', async () => { + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + b.unmountGoals() + + for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + } + expect(b.calls).toHaveLength(0) + }) + it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { const b = await bench({ projection }) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index a6180c6bfc..011834c52d 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/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/registry/README.md -README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 -README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 +README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 +README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index dae8c3ed12..fa227b1c8f 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -10,6 +10,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly hostResolvers = new Map>() private readonly clients = new Map>() private readonly changes: ChangeSource @@ -347,16 +349,56 @@ class ContextStore { key: K, provider: TypeRTHostContextProvider>, ) => this.registerHost(ctx, key, provider), + configureHost: >( + key: K, + resolver: TypeRTHostContextResolver>, + ) => this.configureHost(ctx, key, resolver), registerClient: >( key: K, binder: TypeRTClientContextBinder>, ) => this.registerClient(ctx, key, binder), - getHost: key => this.hosts.get(key)?.provider, + getHost: key => this.getHost(key), getClient: key => this.clients.get(key)?.provider, subscribe: listener => this.changes.subscribe(ctx, listener), } } + private getHost(key: string): TypeRTHostContextProvider | undefined { + const provider = this.hosts.get(key)?.provider + if (provider === undefined) return undefined + const resolver = this.hostResolvers.get(key)?.provider + if (resolver === undefined) return provider + return { + wire: provider.wire, + wireTypeSymbol: provider.wireTypeSymbol, + resolve: id => resolver.resolve(id), + } + } + + private configureHost( + ctx: Context, + key: string, + resolver: TypeRTHostContextResolver, + ): TypeRTDisposer { + validateSegment('Context key', key) + if (this.hostResolvers.has(key)) throw new Error(`typert: host-context "${key}" resolver is already configured`) + const entry: ProviderEntry = { + provider: { resolve: async id => resolver(id as Wire) }, + owner: {}, + } + const { hostResolvers, changes } = this + return ctx.effect(function* () { + hostResolvers.set(key, entry) + changes.emit({ kind: 'host-context', key }) + yield () => { + /* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */ + if (hostResolvers.get(key) !== entry) return + hostResolvers.delete(key) + changes.emit({ kind: 'host-context', key }) + } + }, `typert.contexts.configureHost(${JSON.stringify(key)})`) + } + private registerHost(ctx: Context, key: string, provider: TypeRTHostContextProvider): TypeRTDisposer { validateSegment('Context key', key) validateWireName('Context wire field', provider.wire) @@ -392,6 +434,10 @@ class ContextStore { } } +interface HostContextResolverEntry { + resolve(id: unknown): Promise +} + /** * Registry of generated schemas, package reflection, invocations, and Remote * dependency providers. diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 087cf00fc4..92b81803c7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -389,6 +389,36 @@ describe('TypertRegistry', () => { await disposeReloadedProvider() }) + it('configures an asynchronous Host Context resolver independently of provider load order', async () => { + const ctx = await makeCtx() + const fallback = ctx.extend() + const configured = ctx.extend() + const disposeResolver = ctx.typert.contexts.configureHost('registryFixture', async id => + id === 'configured' ? configured : undefined) + + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured') + + await disposeProvider() + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + + await disposeResolver() + expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('fallback')).toBe(fallback) + await disposeReloadedProvider() + }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { const ctx = await makeCtx() const changes: string[] = [] diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 510b8d3854..6c21127e54 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/type-meta/README.md -README.md: b394c843409e840b75bbb08b128614379e528001 -README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 +README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c +README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index b394c84340..a76169742c 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5bd9bb1828..6f2d2fd6e2 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 -查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1418c9d7f2..2f687f985f 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -50,6 +50,7 @@ export type { TypeRTContextWire, TypeRTDisposer, TypeRTHostContextProvider, + TypeRTHostContextResolver, TypeRTLocalRegistry, TypeRTLookup, TypeRTLookupDefinition, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index b65690115f..ed309b7857 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -238,9 +238,14 @@ export interface TypeRTHostContextProvider { * @param id - validated wire identity. * @returns the scoped Context, or `undefined` when unavailable. */ - resolve(id: Wire): Context | undefined + resolve(id: Wire): Context | undefined | Promise } +/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */ +export type TypeRTHostContextResolver = ( + id: Wire, +) => Context | undefined | Promise + /** Client resolver for the identity carried by the calling scoped Context. */ export interface TypeRTClientContextBinder { /** @@ -367,6 +372,17 @@ export interface TypeRTContextRegistry { key: K, provider: TypeRTHostContextProvider>, ): TypeRTDisposer + /** + * Override one Host Context key's identity policy for the calling fiber. + * Configuration may precede provider registration and restores the provider's default resolver on disposal. + * @param key - merge-declared Context key. + * @param resolver - composition-owned resolver used by every Host Context lookup of this key. + * @returns disposer restoring the provider's default resolver. + */ + configureHost>( + key: K, + resolver: TypeRTHostContextResolver>, + ): TypeRTDisposer /** * Register a Client Context identity binder. * @param key - merge-declared Context key. From d3b7ff17f005096031a5e25c255b0010d1fca13c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:58:43 +0800 Subject: [PATCH 63/88] fix: ci --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf763dca98..4a73dc06ad 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2593,7 +2593,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:400`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:446`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` From 146097368b4e2398db7b1a866144ab6d363f2803 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:21:37 +0800 Subject: [PATCH 64/88] fix(api-gateway): type async service disposer --- packages/api/gateway/src/client/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index ddef36e288..a9343823ff 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -11,6 +11,7 @@ import type { InvocationDescriptor, TypeRTClientApi, TypeRTCodec, + TypeRTDisposer, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' @@ -291,7 +292,7 @@ class ScopedRemoteNamespace { private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() - private disposeService: (() => void) | undefined + private disposeService: TypeRTDisposer | undefined readonly name: string static assertMethodAvailable(namespace: string, method: string): void { @@ -348,7 +349,7 @@ class ScopedRemoteNamespace { if (this.methods.size !== 0) return const disposeService = this.disposeService this.disposeService = undefined - disposeService?.() + void disposeService?.() } } From d6ffd87c5f1b193d698620e716a261743b9324dc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:28:30 +0800 Subject: [PATCH 65/88] refactor(api): expose traced remote namespaces --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 72 +-- ...026-08-02-typert-remote-method-calls.zh.md | 72 +-- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 22 +- docs/api-gateway.zh.md | 22 +- docs/core-data-structures/typert.i18n.yaml | 4 +- docs/core-data-structures/typert.md | 16 +- docs/core-data-structures/typert.zh.md | 16 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 6 +- packages/api/README.zh.md | 6 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 8 +- packages/api/gateway/README.zh.md | 8 +- packages/api/gateway/src/client/index.ts | 414 +++++++++++------- packages/api/gateway/tests/client.spec.ts | 245 +++++------ packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 6 +- packages/api/remotes/README.zh.md | 6 +- packages/api/remotes/src/client/index.ts | 16 +- packages/api/remotes/tests/built-lib.e2e.ts | 8 +- .../client/runtime/src/client/agents/scope.ts | 11 +- .../runtime/src/client/contract/sessions.ts | 5 +- packages/client/runtime/src/client/index.ts | 4 +- .../client/runtime/tests/client-apply.spec.ts | 3 +- .../client/runtime/tests/wire-events.spec.ts | 3 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- packages/client/ui-goal/src/client/index.ts | 12 +- .../ui-goal/tests/browser-plugin.spec.tsx | 15 +- .../generator/tests/remote-model.spec.ts | 10 +- packages/typert/type-meta/src/index.ts | 2 +- packages/typert/type-meta/src/types.ts | 10 +- 38 files changed, 566 insertions(+), 492 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 1e4aeaabd7..341bf44923 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b -2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b +2026-08-02-typert-remote-method-calls.md: a8254090e042e4b359ae74fc5c19bad8abc5ef89 +2026-08-02-typert-remote-method-calls.zh.md: f1b7e5f9c61b474379962ce007e5d6bb966e5ebd diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 3d5a79fd4a..a8254090e0 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -18,11 +18,11 @@ The Host and Browser Client use separate TypeScript Programs because each side a A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. -The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client API Service. The projection and API abstraction remain platform-independent so that a future TUI can reuse them. +The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them. -`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.api`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. +`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. -`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientApi` contract through Cordis rather than importing the concrete Gateway implementation. +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and TypeRT lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypeRTClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -33,12 +33,12 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | TypeRT generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, TypeRT interception, and legacy API Proxy fallback | -| API Gateway's Client face | `ctx.api` | Mounts Remote contributions, materializes root and scoped APIs, and delegates canonical calls to `ctx.connection.rpc` | +| API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | | Business packages such as Goal | Existing business Services | Declare only bindings, Remote methods, and canonical DTOs, and export the generated `/remote` subpath | -The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client API does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Client API namespaces. +The Host Gateway does not depend on concrete implementations of `ctx.agents`, `ctx.sessions`, `ctx.goals`, or `ctx.httpServer`. The Client Remote does not understand the physical carrier, and Connection does not understand Goal, Agent, lookup, `InvocationDescriptor`, or Remote namespaces. ## Business declarations @@ -121,7 +121,7 @@ The Client also registers an `agent` Context binder. The binder only retrieves a ## InvocationDescriptor -TypeRT, the permissive SRC parser, Host Gateway, and Client API exchange one canonical description: +TypeRT, the permissive SRC parser, Host Gateway, and Client Remote exchange one canonical description: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` is the external short name used by the endpoint and Client API; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. +`method` is the external short name used by the endpoint and Client Remote; `implementation` is the actual member name on the Host receiver. `implementation` may be omitted when the two names match. A `direct` descriptor retains the original Service instance as the receiver. A Context descriptor first uses the corresponding Context provider to find the scoped Context, then resolves the receiver by the descriptor's service key. The strict generator writes `scope` only when a direct method has exactly one lookup parameter, a `TypeRTContextMap` declaration with the same name exists, and both use the same wire type symbol. `scope.wire` must identify that lookup parameter. It declares that a consumer may fill this parameter from the Context in which the call occurs, without changing the Host receiver or endpoint. No scoped projection is generated when there are multiple lookups, no Context declaration, or mismatched wire types; a type mismatch is a build error. @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Consequently, `SessionId`, the Agent wire ID, the request, and the result all refer to the same TypeScript declaration in the Host and Browser Client. A future TUI can reuse them without a second set of types. Go to Definition, renames, and Find References for a DTO return to the one source location for the business type instead of stopping at a copy in a generated file. -Remote API methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.api.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. +Remote methods themselves use declaration-map navigation. TypeRT anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. -TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client API may use it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. +TypeRT generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client Remote uses it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by TypeRT need no additional names. @@ -238,7 +238,7 @@ This import brings the `.d.ts` map augmentation into the current TypeScript proj The business package's published files must include both `lib/typert.remote-client.d.ts.map` and the `src` file referenced by that map. The generated DTS refers to its adjacent map with `//# sourceMappingURL=typert.remote-client.d.ts.map`; the map source points from `lib` to the business source by a relative path such as `../src/index.ts`. The `/remote` export does not list the map separately; the package `files` field publishes it together with the source. -Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the API Service. +Code that needs only static types may use `import type {} from '@deepseek-ai/dsh-goal/remote'`. This import is erased at runtime, loads no JS, and cannot trigger runtime registration. An environment that makes real calls must pass the contribution from a normal value import to the Client Remote Service. Workspace resolution for `/remote` must explicitly target generated `lib` artifacts and must not let a general package-to-`src` paths rule redirect it to Host source. Ordinary business imports may continue resolving to SRC or LIB according to each environment's existing rules. @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root API type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. +`TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. In this phase, only the Client Agent Context gains `goals`; the Root Context does not. A future TUI must preserve the same Scope restriction. +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. -`RemoteApi` remains platform-independent, and the Browser Client uses it as its `ClientApi`. If a future TUI reuses this type, it must likewise access it through a dedicated API object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. +`TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. ## Client TypeRT and the API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientApi` contract and Cordis `ctx.api` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. +Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, not directly on the API Gateway or the runtime entry of each business `/remote`. API Remotes consumes the shared `TypeRTClientRemote` contract and Cordis `ctx.remote` service, then re-exports declarations so the selected Remote map reaches business compilation. Adding or removing a complete Client capability changes only this assembly point. -`ctx.api.mount()` registers a contribution with `TypeRT.remotes`, and its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. +`ctx.remote.$mount()` registers a contribution with `TypeRT.remotes`, installs its namespace Services and concrete methods, and resolves only after they are ready. Its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately. -The API Service materializes each `@Remote` descriptor as a real function on the root `api`. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. +The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The API Service creates one root singleton Cordis Service for each scoped namespace and materializes methods on that Service. When `agent.goals.create()` is called, the Cordis tracker rebinds the Service's `this.ctx` to the current Agent Context. The method then asks the corresponding Context binder for identity from `this.ctx`. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. +Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -The Root `Context` does not merge the scoped `goals` type; only `AgentContext` gains that property through `RemoteContextApi<'agent'>`. If a caller bypasses the type system and dynamically calls a scoped method from Root, the binder reports an explicit error. If the Client already has a Cordis service with the same name, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. +The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteContextApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. -Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The API Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. +Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. ## Cross-environment isomorphism constraints Remote API is a consumer capability, not a synonym for Browser API. The shipped runtime implements Browser Client contribution mounting, Connection RPC calls, and Agent Scope association. -Remote DTS, Remote JS, `RemoteApi`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. +Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. @@ -421,7 +421,7 @@ The Remote payload is a named JSON object, not a positional array, and does not The complete path is: ```text -ctx.api.goals.create(sessionId, request, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ The Gateway does not handle per-method permissions, caller identity, idempotency ## Connection and protocol boundaries -The API Service owns Remote contributions, method materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client API types. +The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types. The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. @@ -451,8 +451,8 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. -- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict API methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged API types to business packages through the shared `TypeRTClientApi` contract. +- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypeRTClientRemote` contract. - Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. - API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. @@ -460,7 +460,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. @@ -482,7 +482,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Generate only Remote DTS, without JS.** Types would work, but the runtime could not enumerate endpoints, codecs, and Context modes without a Proxy or another hand-written registry. The same Host projection therefore emits a Remote JS contribution as well. -**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the API Service. +**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service. **Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. @@ -490,7 +490,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h - Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list. - A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`. -- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `api.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. +- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. - Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. @@ -509,13 +509,13 @@ The permissive SRC descriptor does not validate the internal structure of ordina Canonical public types require business DTOs to have type-only entries, which may expose packages whose Host types and implementation entries are currently mixed. The build rejects those boundaries instead of copying types to conceal them. -Type imports and runtime contributions have different effects. `import type {}` extends only the static API. If a real calling environment omits the value contribution, the API Service must fail with an explicit "Remote not mounted" error. +Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error. Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior. A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. -Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the API exposes `fetch`, an HTTP request, or a route handle to the Gateway/API Service, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. +Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection. Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 3d6ec680ba..f1b7e5f9c6 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -18,11 +18,11 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 -Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client API Service;该投影和 API 抽象保持平台无关,以便未来 TUI 复用。 +Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。 -`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.api`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 +`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 -`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 契约,而不导入具体 Gateway 实现。 +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 TypeRT lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 契约,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -33,12 +33,12 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | TypeRT generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、TypeRT 拦截和旧 API Proxy 回退 | -| API Gateway 的 Client face | `ctx.api` | mount Remote contribution,实体化根 API 和 scoped API,把规范调用交给 `ctx.connection.rpc` | +| API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | | Goal 等业务包 | 既有业务 Service | 只声明 binding、Remote 方法和唯一 DTO,并导出生成的 `/remote` 子路径 | -Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client API 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Client API namespace。 +Host Gateway 不依赖 `ctx.agents`、`ctx.sessions`、`ctx.goals` 或 `ctx.httpServer` 的具体实现。Client Remote 不理解物理 carrier,Connection 也不理解 Goal、Agent、lookup、`InvocationDescriptor` 或 Remote namespace。 ## 业务声明 @@ -121,7 +121,7 @@ Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所 ## InvocationDescriptor -TypeRT、SRC 弱解析器、Host Gateway 和 Client API 之间只交换一种规范描述: +TypeRT、SRC 弱解析器、Host Gateway 和 Client Remote 之间只交换一种规范描述: ```text InvocationDescriptor { @@ -141,7 +141,7 @@ InvocationDescriptor { } ``` -`method` 是 endpoint 和 Client API 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 +`method` 是 endpoint 和 Client Remote 使用的外部短名,`implementation` 是 Host receiver 上的真实成员名;两者相同时可省略 `implementation`。`direct` descriptor 保留原始 Service 实例作为 receiver。Context descriptor 先通过对应 Context provider 找到 scoped Context,再以 descriptor 的 service key 解析 receiver。 严格生成器只在 direct 方法恰好包含一个 lookup 参数、同名 `TypeRTContextMap` 声明存在且两者使用同一 wire 类型 symbol 时写入 `scope`。`scope.wire` 必须指向该 lookup 参数;它声明消费端可以从调用所在 Context 补入这个参数,不改变 Host receiver 或 endpoint。多个 lookup、缺少 Context 声明或 wire 类型不一致时不生成 scoped 投影,其中类型不一致属于构建错误。 @@ -179,9 +179,9 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ 因此 `SessionId`、Agent wire ID、request 和 result 在 Host 与 Browser Client 中都指向同一 TypeScript declaration,未来 TUI 复用时也不需要第二份类型。DTO 的跳转定义、重命名和引用查找回到业务类型的唯一源码位置,而不是停在生成文件中的副本。 -Remote API 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.api.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 +Remote 方法本身使用 declaration map 导航。TypeRT 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 -TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client API 可以用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 +TypeRT 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client Remote 用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 TypeRT 明确支持的简单组合不需要额外命名。 @@ -238,7 +238,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote' 业务 package 的发布文件必须同时包含 `lib/typert.remote-client.d.ts.map` 和 map 指向的 `src` 文件。生成 DTS 以 `//# sourceMappingURL=typert.remote-client.d.ts.map` 引用相邻 map;map 中的 source 从 `lib` 相对指向业务源码,例如 `../src/index.ts`。`/remote` export 不单独列出 map,package `files` 负责把它与源码一起发布。 -仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 API Service。 +仅需要静态类型时可以使用 `import type {} from '@deepseek-ai/dsh-goal/remote'`;这种 import 在运行时会被擦除,不会加载 JS,也不能触发任何运行时注册。需要真实调用的环境必须把普通 value import 得到的 contribution 交给 Client Remote Service。 workspace 对 `/remote` 的解析必须明确指向 `lib` 生成物,不能被通用 package-to-`src` paths 规则带回 Host 源码。普通业务 import 仍可按各环境既有规则解析到 SRC 或 LIB。 @@ -275,18 +275,18 @@ interface TypeRTRemoteContextMap { } ``` -`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 API 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 +`TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: ```text -api.goals.create(agentId, request) -agent.goals.create(request) +ctx.remote.goals.create(agentId, request) +agentCtx.remote.goals.create(request) ``` -Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。本期只有 Client Agent Context 获得 `goals`,Root Context 不获得该属性;未来 TUI 复用时必须维持相同的 Scope 限制。 +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 -`RemoteApi` 保持平台无关,Browser Client 把它作为自己的 `ClientApi`。未来 TUI 若复用该类型,也必须通过专用 API 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 +`TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 ## Client TypeRT 与 API Gateway Client face @@ -303,39 +303,39 @@ TypeRT.remotes 已导入的 Remote contribution import goalsRemote from '@deepseek-ai/dsh-goal/remote' import sessionsRemote from '@deepseek-ai/dsh-session/remote' -ctx.api.mount(goalsRemote) -ctx.api.mount(sessionsRemote) +await ctx.remote.$mount(goalsRemote) +await ctx.remote.$mount(sessionsRemote) ``` -Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientApi` 契约和 Cordis `ctx.api` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 +Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依赖 API Gateway 或各业务 `/remote` 运行时入口。API Remotes 消费共享的 `TypeRTClientRemote` 契约和 Cordis `ctx.remote` 服务,再重新导出声明,使所选 Remote map 进入业务编译;新增或移除整套 Client 能力只修改这一处 assembly。 -`ctx.api.mount()` 把 contribution 注册到 `TypeRT.remotes`,并由调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 +`ctx.remote.$mount()` 把 contribution 注册到 `TypeRT.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。 -API Service 把 `@Remote` descriptor 实体化为根 `api` 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 +Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。API Service 为每个 scoped namespace 建立一个 root singleton Cordis Service,并在该 Service 上实体化方法;Cordis tracker 在 `agent.goals.create()` 调用时把 Service 的 `this.ctx` rebind 到当前 Agent Context。方法再通过对应 Context binder 从 `this.ctx` 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text -root ctx.api.goals.create(agentId, request) +root ctx.remote.goals.create(agentId, request) → direct descriptor → ctx.connection.rpc.call('/api', 'goals/create', { args }) -agent.goals.create(request) - → tracker 将 namespace Service rebind 到 agent Context +agentCtx.remote.goals.create(request) + → remote.goals accessor 捕获 agent Context → agent binder 从 caller Context 取得 agentId → 用 agentId 补入同一 direct descriptor 的 lookup 参数 → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -Root `Context` 不 merge scoped `goals` 类型;只有 `AgentContext` 通过 `RemoteContextApi<'agent'>` 获得该属性。若调用方绕过类型从 Root 动态调用 scoped 方法,binder 明确报错。若 Client 已有同名 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 +根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteContextApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 -生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。API Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 +生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 ## 跨环境同构约束 Remote API 是消费端能力,不等同于 Browser API。已交付的运行时实现 Browser Client contribution 挂载、Connection RPC 调用和 Agent Scope 关联。 -Remote DTS、Remote JS、`RemoteApi`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 +Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 @@ -421,7 +421,7 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 ` 完整链路为: ```text -ctx.api.goals.create(sessionId, request, signal?) +ctx.remote.goals.create(sessionId, request, signal?) → Client InvocationDescriptor 编码 { args: { agentId, request } } → Client 合并 caller signal 与 contribution mount lifetime → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal) @@ -442,7 +442,7 @@ Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。 ## Connection 与协议边界 -API Service 负责 Remote contribution、方法实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client API 类型。 +Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。 Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 @@ -451,8 +451,8 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 -- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 API 方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientApi` 契约向业务包暴露合并后的 API 类型。 +- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypeRTClientRemote` 契约向业务包暴露合并后的 Remote 类型。 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 - API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 @@ -460,7 +460,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 @@ -482,7 +482,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **只生成 Remote DTS,不生成 JS。** 类型可以成立,但运行时无法枚举 endpoint、codec 和 Context 模式,只能依赖 Proxy 或另一份手写注册表,因此同一次 Host 投影同时生成 Remote JS contribution。 -**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 API Service 显式挂载。 +**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。 **为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 @@ -490,7 +490,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。 - 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。 -- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `api.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 +- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 - Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 @@ -509,13 +509,13 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 公共类型唯一性要求业务 DTO 具有纯类型出口,可能暴露现有包中 Host 类型与实现入口混杂的问题。构建会拒绝这些边界,而不是复制类型掩盖问题。 -类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 API;真实调用环境遗漏 value contribution 时,API Service 必须以明确的“Remote 未挂载”错误失败。 +类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的“Remote 未挂载”错误失败。 Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证。 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示“该协议能力已被消费端选择”,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 -Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若接口把 `fetch`、HTTP request 或 route handle 暴露给 Gateway/API Service,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 +Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。 Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 05038eb8b9..d07272c182 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: 090758d58306d5ea806567f0de710a1c1f5ed747 -api-gateway.zh.md: 9d7286b6b86918f3bc1e7a6cdd9bdf04447abc57 +api-gateway.md: 90aa661cc86a4f419e173560c55511c969182990 +api-gateway.zh.md: 6fcbb562b204e71d00833042ee0632bda0217940 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 090758d583..90aa661cc8 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -6,7 +6,7 @@ This is the current-state reference for the TypeRT API Gateway. It describes how ## Programming model -Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.api`. +Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. `@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct Remotes appear under `ctx.api.`; when an `@Remote` method has exactly one lookup parameter and a same-named `TypeRTContextMap` uses the same wire identity, the generator also projects the method without that identity parameter onto the corresponding scoped Context. `@RemoteContext` generates only the scoped invocation interface. +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()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. 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. `@RemoteContext` generates only the scoped invocation interface. ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions on `ctx.api`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. +Client applications assemble only `@deepseek-ai/dsh-api-remotes`. That package imports the `/remote` subpaths of selected business packages as runtime values, mounts their contributions through `ctx.remote.$mount()`, and re-exports the declaration merges from the same files. Adding a Host Remote package is an explicit choice by the Client composition owner; business components do not need to load the TypeRT Gateway or the business package's Remote JS separately. -A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. +A future TUI can assemble the same React-independent `api-remotes` and `ctx.remote` contract, so the Host methods visible to it are likewise limited to the Remote methods selected at generation time. This document does not define or implement the TUI composition. ## Component responsibilities @@ -84,11 +84,11 @@ A future TUI can assemble the same React-independent `api-remotes` and `ctx.api` | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-remotes` | Owns the application Agent/Session identity policy and configures the corresponding TypeRT lookups | | Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | -| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.api`, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | +| Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.` child Services, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and current `/api` HTTP bridge | -The API Gateway package owns the Host dispatcher and Client API as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. +The API Gateway package owns the Host dispatcher and Client Remote endpoint as peer entries, but the two builds never enter the same `ts.Program`. The Host entry does not import the Client Cordis `Context` merge, and the Client entry does not import the Host Gateway service. ## Strict generation pipeline @@ -106,13 +106,13 @@ Each contributing business package writes generated files to its own `lib/` dire Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. -Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.api.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. +Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.remote.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. ## Runtime invocation -Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client API calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. +Remote and API Proxy currently share the Connection's `/api` route; there is no separate `/api2` server or second Connection. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the current HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The TypeRT Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier in the future does not require changes to Remote descriptors or the Client programming interface. @@ -128,7 +128,7 @@ When the Host starts from source through `node --import tsx/esm`, it does not ex The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. -SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client API refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. +SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client Remote refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. ## Development mode diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 9d7286b6b8..6fcbb562b2 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -6,7 +6,7 @@ ## 编程模型 -业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.api` 调用。 +业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 `@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接 Remote 出现在 `ctx.api.`;当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成器还会把去掉该 identity 参数后的方法投影到对应作用域 Context。`@RemoteContext` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteContext` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,13 +67,13 @@ declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId -await ctx.api.goals.create(agentId, { objective: 'ship it' }) -await agentCtx.goals.create({ objective: 'ship it' }) +await ctx.remote.goals.create(agentId, { objective: 'ship it' }) +await agentCtx.remote.goals.create({ objective: 'ship it' }) ``` -Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,并向 `ctx.api` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 +Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导入被选业务包的 `/remote` 子路径,通过 `ctx.remote.$mount()` 挂载贡献,同时重新导出相同文件中的声明合并。增加一个 Host Remote 包是 Client 组合所有者的显式选择;业务组件不需要分别加载 TypeRT Gateway 或业务包的 Remote JS。 -未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.api` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 +未来的 TUI 可以装配同一个不依赖 React 的 `api-remotes` 与 `ctx.remote` 契约,因此它能看到的 Host 方法同样只限于生成时选择的 Remote 方法。本文不定义或实现 TUI 组合。 ## 组件职责 @@ -84,11 +84,11 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | | Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | -| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.api`,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | +| Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.` 子 Service,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与当前 `/api` HTTP bridge | -API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 +API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对等入口,但两侧构建不会进入同一个 `ts.Program`。Host 入口不导入 Client 的 Cordis `Context` 合并,Client 入口也不导入 Host Gateway 服务。 ## 严格生成链路 @@ -106,13 +106,13 @@ API Gateway 包同时拥有 Host dispatcher 与 Client API 两个对等入口, 业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 -Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.api.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 +Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.remote.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 ## 运行时调用 -当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client API 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 +当前 Remote 与 API Proxy 共用 Connection 的 `/api` 路由,不存在独立 `/api2` server 或第二套 Connection。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;当前 HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。TypeRT Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 @@ -128,7 +128,7 @@ Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译 SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 -SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client API 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 +SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client Remote 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 ## 开发模式 diff --git a/docs/core-data-structures/typert.i18n.yaml b/docs/core-data-structures/typert.i18n.yaml index a6e1eb5415..75b7837687 100644 --- a/docs/core-data-structures/typert.i18n.yaml +++ b/docs/core-data-structures/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/typert.md -typert.md: a61ed8587833e03fd5c1246311e62a6ffaeb3bd0 -typert.zh.md: 18c24018f4abd644cf35185c2bd06b6980195481 +typert.md: c70e50e2fea8455eb75dfdf8c309f659ab9cb2f9 +typert.zh.md: 2cd1636d4cc8dbcfa009073b4a8e1dcc8d5897e4 diff --git a/docs/core-data-structures/typert.md b/docs/core-data-structures/typert.md index a61ed85878..c70e50e2fe 100644 --- a/docs/core-data-structures/typert.md +++ b/docs/core-data-structures/typert.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientApi`. +Generated consumer declarations merge direct namespaces into the map inherited by `TypeRTClientRemote`. ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## Consumer API +## Consumer Remote -`ctx.api` exposes only namespaces contributed by imported `/remote` artifacts. Mounting installs the generated descriptors and concrete root/scoped methods as one fiber-owned operation; no JavaScript Proxy or Host Service type enters the consumer. +`ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/core-data-structures/typert.zh.md b/docs/core-data-structures/typert.zh.md index 18c24018f4..2cd1636d4c 100644 --- a/docs/core-data-structures/typert.zh.md +++ b/docs/core-data-structures/typert.zh.md @@ -126,10 +126,10 @@ interface TypeRTService { } ``` -生成的消费方声明会把 direct namespace 合并到 `TypeRTClientApi` 继承的 map 中。 +生成的消费方声明会把 direct namespace 合并到 `TypeRTClientRemote` 继承的 map 中。 ```ts type-equiv -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ interface TypeRTRemoteNamespaceMap {} ``` @@ -186,18 +186,18 @@ interface TypertGateway { } ``` -## 消费方 API +## 消费方 Remote -`ctx.api` 只暴露由已导入 `/remote` 产物贡献的 namespace。挂载会把生成的 descriptor 与具体的 root/scoped 方法作为一项由 fiber 持有的操作统一注册;JavaScript Proxy 与 Host 服务类型都不会进入消费方。 +`ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子 Service,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务 Service 类型都不会进入消费方。 ```ts type-equiv -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b0809af72e..933f204fa0 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: f832956c4c7cbde96613a69db6c636a2246786a7 -development.zh.md: 3ae70e7135ad5faee0e37d99f55cdb41373aab2c +development.md: 37bc88c7c1cfedfbe1a93e08a4cbde833ac32372 +development.zh.md: a738e53cb3434d7930aa82107782a4c22aea1470 diff --git a/docs/development.md b/docs/development.md index f832956c4c..37bc88c7c1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge 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). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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. +Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; 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. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index 3ae70e7135..a738e53cb3 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 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)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 +业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 855eeb8eaa..6a834cdf4d 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/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/README.md -README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df -README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94 +README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1 +README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46 diff --git a/packages/api/README.md b/packages/api/README.md index 0dcded5922..7c75e80124 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -6,10 +6,10 @@ The application-facing Remote stack. `remotes` owns BFF policy and the selected | Package | Role | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. +The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. ## Known Limitations and Deferred Work diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 1b9bb9133a..87bd15fc4e 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -6,10 +6,10 @@ | 包 | 职责 | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` | -| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` | +| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` | +| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | -运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 +运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 ## 已知限制与延期工作 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 41bbb0621f..3a9a0ba50d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/gateway/README.md -README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261 -README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca +README.md: e37359db71c1388667e9e61f538354711e90c0c1 +README.zh.md: 2054febb9a5423297c32b029b40a035062250aab diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 9e3d4d8978..e37359db71 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. +Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -14,13 +14,13 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -## Client service: `ClientApi` (ctx key: `api`) +## Client service: `ClientRemote` (ctx key: `remote`) -`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. +`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -Generated declaration merges provide the TypeScript API through the shared `TypeRTClientApi` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. +Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. ## Model Experience diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 9bbd46c711..2054febb9a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 +为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -14,13 +14,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -## Client 服务:`ClientApi`(ctx key:`api`) +## Client 服务:`ClientRemote`(ctx key:`remote`) -`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 +`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 +生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index a9343823ff..d0429339c8 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -1,37 +1,25 @@ /** * Client projection of generated TypeRT Remote descriptors. Contributions - * install concrete namespace methods; no JavaScript Proxy participates in - * lookup, invocation, or type exposure. + * install traced `remote.` services; no JavaScript Proxy + * participates in method lookup, invocation, or type exposure. */ -import { Service, symbols } from 'cordis' +import { Service } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, - TypeRTClientApi, + TypeRTClientRemote, TypeRTCodec, TypeRTDisposer, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' -type RemoteMethod = (...args: unknown[]) => Promise - interface MountToken { active: boolean readonly abort: AbortController } -interface DirectNamespaceRecord { - readonly value: Record - readonly tokens: Map -} - -interface ScopedNamespaceRecord { - readonly service: ScopedRemoteNamespace - readonly tokens: Map -} - interface ScopedProjection { readonly context: string readonly wire: string @@ -39,13 +27,36 @@ interface ScopedProjection { readonly parameterIndex?: number } -/** Typed API service augmented by generated direct Remote namespaces. */ -export type ClientApi = TypeRTClientApi +interface DirectMethod { + readonly descriptor: InvocationDescriptor + readonly token: MountToken +} + +interface ScopedMethod extends DirectMethod { + readonly projection: ScopedProjection +} + +interface RemoteMethodRecord { + direct?: DirectMethod + scoped?: ScopedMethod +} + +interface BoundContextIdentity { + readonly value: unknown +} + +interface RemoteNamespaceHandle { + readonly service: RemoteNamespaceService + readonly dispose: TypeRTDisposer +} + +/** Typed Remote service augmented by generated direct namespaces. */ +export type ClientRemote = TypeRTClientRemote declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by the Client assembly. */ - api: ClientApi + /** Generated Remote namespaces selected by the Client assembly. */ + remote: ClientRemote } } @@ -53,48 +64,56 @@ declare module 'cordis' { export const inject = ['typert', 'connection'] /** - * Install the typed Client API service. + * Install the typed Client Remote service. * @param ctx - Client Cordis root. */ export function apply(ctx: Context): void { - new ClientApiService(ctx) + new ClientRemoteService(ctx) } -class ClientApiService extends Service implements TypeRTClientApi { +class ClientRemoteService extends Service implements TypeRTClientRemote { private readonly ownerCtx: Context - private readonly direct = new Map() - private readonly scoped = new Map() + private readonly namespaces = new Map() + private mutations = Promise.resolve() constructor(ctx: Context) { - super(ctx, 'api') + super(ctx, 'remote') this.ownerCtx = ctx } - mount(contribution: TypeRTRemoteContribution): ReturnType { - this.validateContribution(contribution) + async $mount(contribution: TypeRTRemoteContribution): ReturnType { const callerCtx = this.ctx + const owned = callerCtx.effect(async () => { + const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution)) + return () => this.enqueue(dispose) + }, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`) + await owned + return async () => { await owned() } + } + + private enqueue(operation: () => T | Promise): Promise { + const result = this.mutations.then(operation, operation) + this.mutations = result.then(() => undefined, () => undefined) + return result + } + + private async mountContribution( + callerCtx: Context, + contribution: TypeRTRemoteContribution, + ): Promise { + this.validateContribution(contribution) const disposeRemote = callerCtx.typert.remotes.register(contribution) - let disposeMethods: () => void | Promise + const installed: TypeRTDisposer[] = [] try { - disposeMethods = callerCtx.effect(() => { - const installed: Array<() => void> = [] - try { - for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) - } catch (error) { - for (const dispose of installed.reverse()) dispose() - throw error - } - return () => { - for (const dispose of installed.reverse()) dispose() - } - }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) + for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor)) } catch (error) { - /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ - Promise.resolve(disposeRemote()).catch(() => {}) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() throw error } return async () => { - await Promise.all([disposeMethods(), disposeRemote()]) + for (const dispose of installed.reverse()) await dispose() + await disposeRemote() } } @@ -112,10 +131,8 @@ class ClientApiService extends Service implements TypeRTClientApi { } methods.add(descriptor.method) table.set(descriptor.namespace, methods) - const live = kind === 'direct' - ? this.direct.get(descriptor.namespace)?.tokens - : this.scoped.get(descriptor.namespace)?.tokens - if (live?.has(descriptor.method) === true) { + const namespace = this.namespaces.get(descriptor.namespace)?.service + if (namespace?.has(kind, descriptor.method) === true) { throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`) } } @@ -124,118 +141,151 @@ class ClientApiService extends Service implements TypeRTClientApi { if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct') if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped') } - for (const namespace of direct.keys()) { - if (!this.direct.has(namespace) && namespace in this) { - throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`) - } - } - for (const [namespace, methods] of scoped) { - const record = this.scoped.get(namespace) - if (record !== undefined) { - for (const method of methods) record.service.assertMethodAvailable(method) - } else { - for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - const property = this.ownerCtx.reflect.props[namespace] - if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { - throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) + const namespaces = new Set([...direct.keys(), ...scoped.keys()]) + for (const namespace of namespaces) { + const service = this.namespaces.get(namespace)?.service + if (service === undefined) { + if (namespace in this) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`) } + const serviceKey = remoteServiceKey(namespace) + const property = this.ownerCtx.reflect.props[serviceKey] + if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) { + throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`) + } + } + for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) { + if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method) + else service.assertMethodAvailable(method) } } } - private install(descriptor: InvocationDescriptor): () => void { + private async install(descriptor: InvocationDescriptor): Promise { const token: MountToken = { active: true, abort: new AbortController() } - const installed: (() => void)[] = [] + const installed: TypeRTDisposer[] = [] try { if (descriptor.invocation.kind === 'direct') { - installed.push(this.installDirect(descriptor, token)) + installed.push(await this.installDirect(descriptor, token)) } const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token)) + if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token)) } catch (error) { token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() throw error } - return () => { + return async () => { /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ if (!token.active) return token.active = false - for (const dispose of installed.reverse()) dispose() token.abort.abort() + for (const dispose of installed.reverse()) await dispose() } } - private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { - let namespace = this.direct.get(descriptor.namespace) - const fresh = namespace === undefined - if (namespace === undefined) { - namespace = { value: Object.create(null) as Record, tokens: new Map() } - Object.defineProperty(this, descriptor.namespace, { - configurable: true, - enumerable: true, - value: namespace.value, - }) - } + private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise { + const namespace = await this.namespace(descriptor.namespace) try { - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) + namespace.service.installDirect(descriptor, token) } catch (error) { - if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + await this.disposeNamespace(descriptor.namespace, namespace) throw error } - if (fresh) this.direct.set(descriptor.namespace, namespace) - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - Reflect.deleteProperty(namespace.value, descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size !== 0) return - this.direct.delete(descriptor.namespace) - Reflect.deleteProperty(this, descriptor.namespace) + return async () => { + if (!namespace.service.remove('direct', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } - private installScoped( + private async installScoped( descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken, - ): () => void { - let namespace = this.scoped.get(descriptor.namespace) - if (namespace === undefined) { - const service = new ScopedRemoteNamespace( - this.ownerCtx, - descriptor.namespace, - (current, currentProjection, currentToken, caller, args) => - this.invoke(current, currentProjection, currentToken, caller, args), - ) - service.install(descriptor, projection, token) - namespace = { service, tokens: new Map() } - this.scoped.set(descriptor.namespace, namespace) - } else { - namespace.service.install(descriptor, projection, token) + ): Promise { + const namespace = await this.namespace(descriptor.namespace) + try { + namespace.service.installScoped(descriptor, projection, token) + } catch (error) { + await this.disposeNamespace(descriptor.namespace, namespace) + throw error } - namespace.tokens.set(descriptor.method, token) - return () => { - /* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */ - if (namespace.tokens.get(descriptor.method) !== token) return - namespace.service.remove(descriptor.method) - namespace.tokens.delete(descriptor.method) - if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) + return async () => { + if (!namespace.service.remove('scoped', descriptor.method, token)) return + await this.disposeNamespace(descriptor.namespace, namespace) } } + private async namespace(name: string): Promise { + let namespace = this.namespaces.get(name) + if (namespace !== undefined) return namespace + let service: RemoteNamespaceService | undefined + const fiber = this.ownerCtx.plugin({ + name: remoteServiceKey(name), + apply: (ctx: Context) => { + service = new RemoteNamespaceService( + ctx, + name, + (direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args), + ) + }, + }) + try { + await fiber + } catch (error) { + await fiber.dispose() + throw error + } + /* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */ + if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`) + namespace = { service, dispose: fiber.dispose } + this.namespaces.set(name, namespace) + return namespace + } + + private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise { + if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return + this.namespaces.delete(name) + await namespace.dispose() + } + + private invokeMethod( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + values: readonly unknown[], + ): Promise { + if (scoped !== undefined) { + const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) + const identity = binder?.identity(callerCtx) + if (identity !== undefined) { + return this.invoke( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + { value: identity }, + ) + } + } + if (direct !== undefined) { + return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values) + } + if (scoped !== undefined) { + return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) + } + throw new Error('client api: Remote method is no longer mounted') + } + private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, token: MountToken, callerCtx: Context, values: readonly unknown[], + boundIdentity?: BoundContextIdentity, ): Promise { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) @@ -251,11 +301,15 @@ class ClientApiService extends Service implements TypeRTClientApi { } const args = Object.create(null) as Record if (projection !== undefined) { - const binder = this.ownerCtx.typert.contexts.getClient(projection.context) - if (binder === undefined) { + const binder = boundIdentity === undefined + ? this.ownerCtx.typert.contexts.getClient(projection.context) + : undefined + if (boundIdentity === undefined && binder === undefined) { throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) } - const identity = binder.identity(callerCtx) + const identity = boundIdentity === undefined + ? binder?.identity(callerCtx) + : boundIdentity.value if (identity === undefined) { throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) } @@ -281,23 +335,19 @@ class ClientApiService extends Service implements TypeRTClientApi { } type InvokeRemote = ( - descriptor: InvocationDescriptor, - projection: ScopedProjection, - token: MountToken, + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace { - private readonly ctx: Context - private readonly ownerCtx: Context - private readonly methods = new Set() - private disposeService: TypeRTDisposer | undefined - readonly name: string +class RemoteNamespaceService extends Service { + private readonly methods = new Map() + private readonly namespace: string static assertMethodAvailable(namespace: string, method: string): void { - if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { - throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) + if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) { + throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`) } } @@ -306,54 +356,92 @@ class ScopedRemoteNamespace { name: string, private readonly invokeRemote: InvokeRemote, ) { - this.ctx = ctx - this.ownerCtx = ctx - this.name = name - Object.defineProperty(this, symbols.tracker, { - value: { associate: name, property: 'ctx' }, - }) + super(ctx, remoteServiceKey(name)) + this.namespace = name } assertMethodAvailable(method: string): void { - ScopedRemoteNamespace.assertMethodAvailable(this.name, method) - if (method in this) { - throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`) + RemoteNamespaceService.assertMethodAvailable(this.namespace, method) + if (method in this && !this.methods.has(method)) { + throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`) } } - install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { - this.assertMethodAvailable(descriptor.method) - const activate = this.methods.size === 0 - const method = descriptor.method + get empty(): boolean { + return this.methods.size === 0 + } + + has(kind: 'direct' | 'scoped', method: string): boolean { + return this.methods.get(method)?.[kind] !== undefined + } + + installDirect(descriptor: InvocationDescriptor, token: MountToken): void { + this.install(descriptor.method, 'direct', { descriptor, token }) + } + + installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { + this.install(descriptor.method, 'scoped', { descriptor, projection, token }) + } + + private install(method: string, kind: 'direct', value: DirectMethod): void + private install(method: string, kind: 'scoped', value: ScopedMethod): void + private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void { + this.assertMethodAvailable(method) + let record = this.methods.get(method) + const fresh = record === undefined + record ??= {} + if (record[kind] !== undefined) { + throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) + } try { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) - if (activate) { - this.disposeService = this.ownerCtx.reflect.provide(this.name, this) + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } catch (error) { - Reflect.deleteProperty(this, method) + if (kind === 'direct') delete record.direct + else delete record.scoped + if (fresh) { + this.methods.delete(method) + Reflect.deleteProperty(this, method) + } throw error } - this.methods.add(method) } - remove(method: string): void { - Reflect.deleteProperty(this, method) + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + const record = this.methods.get(method) + const current = record?.[kind] + /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ + if (record === undefined || current?.token !== token) return false + if (kind === 'direct') delete record.direct + else delete record.scoped + if (record.direct !== undefined || record.scoped !== undefined) return true this.methods.delete(method) - if (this.methods.size !== 0) return - const disposeService = this.disposeService - this.disposeService = undefined - void disposeService?.() + Reflect.deleteProperty(this, method) + return true } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace']) + +function remoteServiceKey(namespace: string): string { + return `remote.${namespace}` +} function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index feae3056c9..216f2359e7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'> +type FixtureContext = Omit & { readonly remote: TypeRTRemoteContextApi<'fixture'> } const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) @@ -105,17 +105,16 @@ describe('Client TypeRT API', () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) - let retained: typeof ctx.api.goals.create | undefined + const businessGoals = { owner: 'host business service' } + const disposeBusinessGoals = ctx.provide('goals', businessGoals) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - retained = scope.api.goals.create - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly + const retained = ctx.remote.goals.create - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', @@ -123,7 +122,7 @@ describe('Client TypeRT API', () => { expect.any(AbortSignal), ) const callerAbort = new AbortController() - await expect(ctx.api.goals.create( + await expect(ctx.remote.goals.create( 'agent-1', { objective: 'cancel me' }, callerAbort.signal, @@ -135,16 +134,18 @@ describe('Client TypeRT API', () => { callerAbort.abort(cancellation) expect(combinedSignal?.aborted).toBe(true) expect(combinedSignal?.reason).toBe(cancellation) - await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('goals')).toBe(businessGoals) expect(ctx.typert.remotes.list()).toEqual([]) await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') + disposeBusinessGoals() }) it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { @@ -156,26 +157,24 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( '/api', 'goals/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' })) - .rejects.toThrow('requires a "fixture" Context') + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' })) + .rejects.toThrow('expected 2 business argument(s)') await assembly.dispose() - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('uses the caller Context identity for scoped namespace methods', async () => { @@ -187,25 +186,23 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => { - scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }) - }, - { inject: ['api'] }, + (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), + { inject: ['remote'] }, )) await assembly - await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( '/api', 'goals/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).goals.rename({ objective: 'land' })) + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' })) .rejects.toThrow('requires a "fixture" Context') await assembly.dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() }) it('rejects weak descriptors and namespace collisions before registration', async () => { @@ -215,12 +212,12 @@ describe('Client TypeRT API', () => { result: { mode: 'src-json' }, } - expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] })) - .toThrow('has no strict codec') - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] })) + .rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/conflict', - descriptors: [{ ...directDescriptor(), namespace: 'mount' }], - })).toThrow('conflicts with the API service') + descriptors: [{ ...directDescriptor(), namespace: '$mount' }], + })).rejects.toThrow('conflicts with the Remote service') expect(ctx.typert.remotes.list()).toEqual([]) }) @@ -235,48 +232,50 @@ describe('Client TypeRT API', () => { const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/direct-duplicates', descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], - })).toThrow('repeats direct method') - expect(() => ctx.api.mount({ + })).rejects.toThrow('repeats direct method') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-duplicates', descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], - })).toThrow('repeats scoped method') + })).rejects.toThrow('repeats scoped method') - const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) - expect(() => ctx.api.mount({ + const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] }) + await expect(ctx.remote.$mount({ package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], - })).toThrow('direct method goals/create is already mounted') + })).rejects.toThrow('direct method goals/create is already mounted') await disposeDirect() - const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) - expect(() => ctx.api.mount({ + const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] }) + await expect(ctx.remote.$mount({ package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], - })).toThrow('scoped method goals/rename is already mounted') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scoped method goals/rename is already mounted') + await expect(ctx.remote.$mount({ package: '@fixture/service-method-conflict', descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], - })).toThrow('conflicts with its namespace service') - const scopedService = ctx.get('goals') as unknown as object + })).rejects.toThrow('conflicts with its namespace service') + const scopedService = ctx.get('remote.goals') as unknown as object Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/service-own-property-conflict', descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], - })).toThrow('conflicts with its namespace service') + })).rejects.toThrow('conflicts with its namespace service') Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() - expect(() => ctx.api.mount({ + const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' }) + await expect(ctx.remote.$mount({ package: '@fixture/context-property-conflict', descriptors: [{ ...context, namespace: 'typert' }], - })).toThrow('conflicts with an existing Context property') + })).rejects.toThrow('conflicts with an existing Remote namespace') + await disposeRemoteTypert() - const disposeMultipleScoped = ctx.api.mount({ + const disposeMultipleScoped = await ctx.remote.$mount({ package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) - await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenLastCalledWith( '/api', 'goals/rename', @@ -286,41 +285,6 @@ describe('Client TypeRT API', () => { await disposeMultipleScoped() }) - it('rolls back direct projection when scoped installation fails', async () => { - const ctx = await bench(vi.fn()) - const disposeScoped = ctx.api.mount({ - package: '@fixture/scoped-base', - descriptors: [contextDescriptor()], - }) - const defineProperty = Object.defineProperty - let createDefinitions = 0 - const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - // The direct projection defines `create` first; fail the following scoped projection. - if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure') - return defineProperty(target, key, attributes) - }) - - try { - expect(() => ctx.api.mount({ - package: '@fixture/failing-install', - descriptors: [directDescriptor()], - })).toThrow('simulated scoped installation failure') - } finally { - definePropertySpy.mockRestore() - } - - expect((ctx.api as unknown as Record).goals).toBeUndefined() - expect(ctx.get('goals') !== undefined).toBe(true) - expect(ctx.typert.remotes.list()).toHaveLength(1) - - const disposeRetry = ctx.api.mount({ - package: '@fixture/retry', - descriptors: [directDescriptor()], - }) - await disposeRetry() - await disposeScoped() - }) - it('rolls back earlier descriptors when a later descriptor fails to install', async () => { const ctx = await bench(vi.fn()) const { scope: _scope, ...first } = directDescriptor() @@ -335,17 +299,17 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) - .toThrow('fixture later-descriptor failure') + await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .rejects.toThrow('fixture later-descriptor failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) - expect(ctx.api.goals.create).toBeTypeOf('function') - expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') + expect((ctx.remote.goals as unknown as Record).archive).toBeTypeOf('function') await retry() }) @@ -353,7 +317,7 @@ describe('Client TypeRT API', () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() const context = contextDescriptor() - expect(() => ctx.api.mount({ + await expect(ctx.remote.$mount({ package: '@fixture/weak-parameter', descriptors: [{ ...direct, @@ -361,19 +325,19 @@ describe('Client TypeRT API', () => { ? { ...parameter, codec: { mode: 'src-json' } } : parameter), }], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/weak-context', descriptors: [{ ...context, invocation: { ...context.invocation, codec: { mode: 'src-json' } }, } as InvocationDescriptor], - })).toThrow('has no strict codec') - expect(() => ctx.api.mount({ + })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.$mount({ package: '@fixture/malformed-scope', descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], - })).toThrow('scope must select its only lookup parameter') - expect(() => ctx.api.mount({ + })).rejects.toThrow('scope must select its only lookup parameter') + await expect(ctx.remote.$mount({ package: '@fixture/ambiguous-scope', descriptors: [{ ...direct, @@ -382,7 +346,7 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, }], }], - })).toThrow('scope must select its only lookup parameter') + })).rejects.toThrow('scope must select its only lookup parameter') }) it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { @@ -390,27 +354,29 @@ describe('Client TypeRT API', () => { .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) const descriptor = directDescriptor() - const dispose = ctx.api.mount({ + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [descriptor, contextDescriptor()], }) - const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise - const goals = (ctx as FixtureContext).goals + const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise + const goals = (ctx as FixtureContext).remote.goals const rename = goals.rename as unknown as (...args: unknown[]) => Promise await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) .rejects.toThrow('got 4') await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') - await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' })) + .rejects.toThrow('expected 2 business argument(s)') + await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' ctx.set('connection', undefined) - await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') await dispose() }) @@ -427,14 +393,14 @@ describe('Client TypeRT API', () => { id: '@fixture/goals#goals/archive', method: 'archive', } - const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) - const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) await expect(invocation).rejects.toThrow('withdrawn during invocation') - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) it('preserves a __proto__ wire parameter as an own named argument', async () => { @@ -453,9 +419,9 @@ describe('Client TypeRT API', () => { codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, }], } - const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) - const method = (ctx.api.goals as unknown as Record Promise>).prototype + const method = (ctx.remote.goals as unknown as Record Promise>).prototype await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) const payload = call.mock.calls[0]?.[2] as { readonly args: Record } expect(Object.getPrototypeOf(payload.args)).toBeNull() @@ -464,23 +430,23 @@ describe('Client TypeRT API', () => { await dispose() }) - it('rolls back Remote registration when concrete method installation fails', async () => { + it('rolls back Remote registration when namespace Service startup fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { - if (key === 'goals') throw new Error('fixture installation failure') + if (key === Service.tracker) throw new Error('fixture namespace startup failure') return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) - .toThrow('fixture installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .rejects.toThrow('fixture namespace startup failure') await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) } finally { spy.mockRestore() } - const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -492,16 +458,21 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] })) - .toThrow('fixture direct method installation failure') + await expect(ctx.remote.$mount({ + package: '@fixture/direct-method-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture direct method installation failure') } finally { spy.mockRestore() } - expect((ctx.api as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).goals).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] }) - expect(ctx.api.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ + package: '@fixture/direct-method-retry', + descriptors: [directDescriptor()], + }) + expect(ctx.remote.goals.create).toBeTypeOf('function') await retry() }) @@ -513,41 +484,41 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) - .toThrow('fixture scoped installation failure') + await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .rejects.toThrow('fixture scoped installation failure') } finally { spy.mockRestore() } - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) - const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) - expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('remote.goals') as unknown as Record).rename).toBeTypeOf('function') await retry() }) it('unregisters an empty scoped namespace so another provider can claim its name', async () => { const ctx = await bench(vi.fn()) - const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) - expect(ctx.get('goals')).toBeDefined() + const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('remote.goals')).toBeDefined() await dispose() - expect(ctx.get('goals')).toBeUndefined() + expect(ctx.get('remote.goals')).toBeUndefined() const replacement = { owner: 'replacement' } - const disposeReplacement = ctx.reflect.provide('goals', replacement) - expect(ctx.get('goals')).toBe(replacement) + const disposeReplacement = ctx.reflect.provide('remote.goals', replacement) + expect(ctx.get('remote.goals')).toBe(replacement) await disposeReplacement() }) it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) - ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) let failure: unknown try { - await ctx.api.goals.create('agent-1', { objective: 'ship' }) + await ctx.remote.goals.create('agent-1', { objective: 'ship' }) } catch (error) { failure = error } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c3c13a8049..82947331c5 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: cf54a56a849246d4efdca09cadd42e157064bdee -README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f +README.md: 7f6a2114d900413d972584c0f1c141b7f835ba36 +README.zh.md: cce263747d696570f362811556fa6f5c0be0a0f5 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index cf54a56a84..7f6a2114d9 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. `createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. -The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway. +The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. -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.api` contract. +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. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 5cd7ef21c9..cce263747d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 `createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 -当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。 +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。 -本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。 +本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。 ## 模型体验 diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 1bc36b62ee..ebd342300e 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -2,25 +2,25 @@ import type { Context } from 'cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' -import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' -export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta' +export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' declare module 'cordis' { interface Context { - /** Generated direct Remote namespaces selected by this Client assembly. */ - api: TypeRTClientApi + /** Generated Remote namespaces selected by this Client assembly. */ + remote: TypeRTClientRemote } } -/** Required service: the typed Client API contribution mount. */ -export const inject = ['api'] +/** Required service: the typed Client Remote contribution mount. */ +export const inject = ['remote'] /** * Mount the Host capabilities explicitly selected for this Client assembly. * @param ctx - Client Cordis root carrying the typed API service. */ -export function apply(ctx: Context): void { - ctx.api.mount(goalsRemote) +export function apply(ctx: Context): Promise<() => Promise> { + return ctx.remote.$mount(goalsRemote) } diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index b8f6c81e98..af584cba7f 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -143,18 +143,18 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { let invalidRejected = false try { - await client.api.goals.create(rootAgent.id, { objective: 1 }) + await client.remote.goals.create(rootAgent.id, { objective: 1 }) } catch { invalidRejected = true } - const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' }) - const rootEdit = await client.api.goals.edit( + const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' }) + const rootEdit = await client.remote.goals.edit( rootAgent.id, rootResult.ref, { objective: 'edited root goal' }, ) const agentContext = client.extend({ builtAgentId: scopedAgent.id }) - const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) + const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 }) const result = { invalidRejected, rootResult, diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index ba4fd8ede7..1154d10feb 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,7 +18,12 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote, TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' + +/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ +export type AgentContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteContextApi<'agent'> +} /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.client.scope') @@ -30,7 +35,7 @@ export interface AgentScopeHandle { * through it (passing it as the dispatch subject routes to this agent's * tagged listeners plus every untagged one). */ - ctx: Context & TypeRTRemoteContextApi<'agent'> + ctx: AgentContext /** Backing fiber (dispose tears down every scope-owned registration). */ fiber: Fiber } @@ -55,7 +60,7 @@ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { const tag = scopeOf(listenerCtx) return tag === undefined || tag === key }, - }) as Context & TypeRTRemoteContextApi<'agent'> + }) as AgentContext return { fiber, ctx: scoped, diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 8e9c530720..2af2ef51c8 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -11,8 +11,8 @@ import type { Context } from 'cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import type { AgentContext } from '../agents/scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionBinding, SessionListState, SessionProvideDescriptor, @@ -20,8 +20,7 @@ import type { import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from './store.ts' -/** Client Cordis Context carrying one Agent identity and its generated Remote namespaces. */ -export type AgentContext = Context & TypeRTRemoteContextApi<'agent'> +export type { AgentContext } from '../agents/scope.ts' /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a9d2bb0d7d..b772e315a3 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -179,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the typed Remote API, wire handle, and Client TypeRT registry. */ -export const inject = ['api', 'connection', 'typert'] +/** Required services: the Remote root and Goal namespace, wire handle, and Client TypeRT registry. */ +export const inject = ['remote', 'remote.goals', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 5635793122..e9b387fb00 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -38,7 +38,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 5ab644682a..703c5b1728 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -32,7 +32,8 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('api', {}) + ctx.reflect.provide('remote', {}) + ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index f30f14ed48..55853ef4bd 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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-goal/README.md -README.md: b99aaf624a7d669879ba668938ee455e3cdc68ad -README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4 +README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0 +README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index b99aaf624a..a53fb3a89e 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 3d823d0130..1ad9f50aee 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.api.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index bea4f67df2..2c041e0eae 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -9,7 +9,7 @@ * Goal creation stays on the /goal host command. */ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary. +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ -export const inject = ['slots', 'sessions', 'api', 'locale'] +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] /** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ async function settle(invoke: () => Promise): Promise { @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.remote.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.remote.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.remote.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index f900682712..756968136e 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -10,7 +10,7 @@ * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. */ -import { Context } from 'cordis' +import { Context, Service } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' @@ -71,8 +71,17 @@ async function bench(options: { clear: answer(`${prefix}/clear`, ref), }) let activeGoals: ReturnType | undefined = goals('goals') - ctx.provide('api', { - get goals() { return activeGoals }, + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.goals', { + get edit() { return activeGoals?.edit }, + get pause() { return activeGoals?.pause }, + get resume() { return activeGoals?.resume }, + get clear() { return activeGoals?.clear }, }) await ctx.plugin(SlotsService).await() ctx.slots.register({ diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 27bdac2fac..eaaf680cc6 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -593,8 +593,8 @@ const created: Promise = create('agent-1', { title: 'ship' }) const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) const renamed: Promise = rename({ ref: 'goal-1', title: 'land' }) -declare const ctx: { api: TypeRTRemoteNamespaceMap } -const navigated: Promise = ctx.api.goals.create('agent-1', { title: 'navigate' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +const navigated: Promise = ctx.remote.goals.create('agent-1', { title: 'navigate' }) void contribution void created void cancellable @@ -643,7 +643,7 @@ void navigated readFile: path => ts.sys.readFile(path), realpath: path => ts.sys.realpath?.(path) ?? path, }) - const navigation = 'ctx.api.goals.create' + const navigation = 'ctx.remote.goals.create' const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1 const definitions = languageService.getDefinitionAtPosition(consumerPath, position) const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath) @@ -672,8 +672,8 @@ function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): const consumerPath = join(consumerRoot, 'consumer-without-remote.ts') writeFileSync(consumerPath, ` import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta' -declare const ctx: { api: TypeRTRemoteNamespaceMap } -ctx.api.goals.create('agent-1', { title: 'must not compile' }) +declare const ctx: { remote: TypeRTRemoteNamespaceMap } +ctx.remote.goals.create('agent-1', { title: 'must not compile' }) `) const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json') writeFileSync(configPath, JSON.stringify({ diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 2f687f985f..774c6d3b32 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,7 +41,7 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, - TypeRTClientApi, + TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, TypeRTContext, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index ed309b7857..5e7c20cd7c 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -77,7 +77,7 @@ export type TypeRTRemoteContextApi = { TypeRTRemoteContextNamespace } -/** Merge-extensible direct namespace surface generated for Client API services. */ +/** Merge-extensible direct namespace surface generated for Client Remote services. */ export interface TypeRTRemoteNamespaceMap {} /** Awaitable disposer returned by Cordis-owned TypeRT registrations. */ @@ -176,14 +176,14 @@ export interface TypeRTRemoteContribution { readonly descriptors: readonly InvocationDescriptor[] } -/** Client API capability implemented by the Gateway and consumed by Remote assemblies. */ -export interface TypeRTClientApi extends TypeRTRemoteNamespaceMap { +/** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ +export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { /** * Mount one generated Host-for-Client contribution in the caller's fiber. * @param contribution - explicitly selected Remote package artifact. - * @returns disposer withdrawing descriptors and concrete methods together. + * @returns disposer after namespace services and concrete methods are ready. */ - mount(contribution: TypeRTRemoteContribution): TypeRTDisposer + $mount(contribution: TypeRTRemoteContribution): Promise } /** From d362cdb54f228ddd0edda4771b5db64420eec04e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:35:50 +0800 Subject: [PATCH 66/88] refactor(typert): rename RemoteContext to RemoteScope --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +-- .../2026-08-02-typert-remote-method-calls.md | 36 +++++++++---------- ...026-08-02-typert-remote-method-calls.zh.md | 36 +++++++++---------- docs/api-gateway.i18n.yaml | 4 +-- docs/api-gateway.md | 14 ++++---- docs/api-gateway.zh.md | 14 ++++---- docs/development.i18n.yaml | 4 +-- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/api/gateway/README.i18n.yaml | 4 +-- packages/api/gateway/README.md | 4 +-- packages/api/gateway/README.zh.md | 4 +-- packages/api/gateway/tests/client.spec.ts | 6 ++-- packages/api/gateway/tests/gateway.spec.ts | 10 +++--- .../client/runtime/src/client/agents/scope.ts | 4 +-- packages/typert/generator/src/analyzer.ts | 14 ++++---- packages/typert/generator/src/emitter.ts | 2 +- .../remote-model/packages/remote/src/index.ts | 4 +-- .../fixtures/remote-model/type-meta.d.ts | 4 +-- .../generator/tests/remote-model.spec.ts | 18 +++++----- packages/typert/type-meta/README.i18n.yaml | 4 +-- packages/typert/type-meta/README.md | 4 +-- packages/typert/type-meta/README.zh.md | 4 +-- packages/typert/type-meta/src/index.ts | 14 ++++---- packages/typert/type-meta/src/types.ts | 20 +++++------ .../type-meta/tests/fixtures/source-launch.ts | 4 +-- .../typert/type-meta/tests/type-meta.spec.ts | 14 ++++---- 27 files changed, 127 insertions(+), 127 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 341bf44923..71ded0fa8d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: a8254090e042e4b359ae74fc5c19bad8abc5ef89 -2026-08-02-typert-remote-method-calls.zh.md: f1b7e5f9c61b474379962ce007e5d6bb966e5ebd +2026-08-02-typert-remote-method-calls.md: 215c647bcd7413b92625ee670022dc7316e3045a +2026-08-02-typert-remote-method-calls.zh.md: 0ce431b7cbc948e937f722f2769b15a1d26dcec9 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index a8254090e0..215c647bcd 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -16,7 +16,7 @@ The Host and Browser Client use separate TypeScript Programs because each side a ## Decision -A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteContext()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. +A business Service extends `GatewayService` and declares callable methods with `@Remote` or `@RemoteScope()`. A Service that already has another base class may instead expose the same binding through `bindTypeRTGateway()`. TypeRT generates the Host-local reflection artifact and a platform-independent Remote consumer projection from the Host Program. The Client Program continues to generate its own local reflection artifact independently. The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. The `.d.ts` exposes only methods marked with a Remote decorator and refers to the business package's single public type symbols. The `.d.ts.map` navigates consumer API methods back to their Host business method implementations. The `.js` carries endpoint, parameter, Context, and Zod information for the same contract. At the assembly layer, the Browser Client mounts the required Remote JS contributions onto the Client Remote Service. The projection and Remote abstraction remain platform-independent so that a future TUI can reuse them. @@ -64,7 +64,7 @@ export class GoalService extends GatewayService { `goals` is the explicit Cordis service key passed to `super()` and is the default wire namespace. Pass a `namespace` option as the third argument only when the protocol namespace genuinely needs to differ from the service key. -Use `@RemoteContext()` when the Service receiver must be resolved within an isolated kind of Context. Context identity does not enter the business method's parameters: +Use `@RemoteScope()` when the Service receiver must be resolved within an isolated kind of Context. Scope identity does not enter the business method's parameters: ```text export class ScopedGoalService extends GatewayService { @@ -72,28 +72,28 @@ export class ScopedGoalService extends GatewayService { super(ctx, 'goals') } - @RemoteContext('agent', 'create') + @RemoteScope('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { // Runs against the goals service resolved from the Agent Context. } } ``` -An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteContext('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. +An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. TypeRT does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Context, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on the lightweight `@deepseek-ai/dsh-type-meta`. It provides `GatewayService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteContext('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `GatewayService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-type-meta`. It writes no custom properties to a Service instance, prototype, constructor, or method function. In LIB mode, the TypeRT compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `GatewayService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. -## Lookup and Remote Context registration +## Lookup and Remote Scope registration The Gateway has no built-in branches for Agent, Session, or other business objects. Each object-owning package provides both a static declaration and a runtime provider: @@ -115,7 +115,7 @@ The static declaration tells TypeRT that `Agent` corresponds to `SessionId` on t Lookup objects such as Agent and Session may each occupy only one top-level parameter position. An ordinary JSON request may be passed as another complete parameter, but this design does not support `request.agent`, object destructuring, arrays of objects, nested lookups, or searching arbitrary complex structures for IDs. -Remote Context uses a separate merge-extensible map and provider. The Agent package registers an `agent` Context provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. +Remote Scope uses a separate merge-extensible map and Context provider. The Agent package registers an `agent` provider that locates the Agent Context from its wire identity and resolves the Service key named by the descriptor from that Context. The Gateway does not know the internal structure of an Agent Context. The Client also registers an `agent` Context binder. The binder only retrieves a `SessionId` from the Context in which a call occurs; it neither enumerates Scopes nor copies methods into each one. A Cordis Service tracker automatically rebinds a scoped namespace to the current Agent Context. @@ -267,7 +267,7 @@ interface TypeRTRemoteNamespaceMap { goals: TypeRTRemoteNamespace$676f616c73 } -interface TypeRTRemoteContextMap { +interface TypeRTRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, @@ -277,14 +277,14 @@ interface TypeRTRemoteContextMap { `TypeRTRemoteMap` preserves canonical endpoint signatures for protocol typing and reflection. The root Remote type reads `TypeRTRemoteNamespaceMap` directly instead of deriving methods indirectly through a key-remapped mapped type; the TypeScript Language Service cannot reliably navigate such indirect properties through a declaration map. A namespace interface name encodes the namespace's UTF-8 bytes as hexadecimal, so `goals` deterministically becomes `TypeRTRemoteNamespace$676f616c73`. Different packages generate the same interface name for the same namespace and use module augmentation to merge their methods, while `TypeRTRemoteNamespaceMap.goals` always refers to that one type. -TypeRT projects `TypeRTRemoteContextMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: +TypeRT projects `TypeRTRemoteScopeMap` onto a dedicated Scope type according to its Context key. The final programming interface remains: ```text ctx.remote.goals.create(agentId, request) agentCtx.remote.goals.create(request) ``` -The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteContext('agent')` method also omits a separate Context identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. +The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteScope('agent')` method also omits a separate Scope identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. `TypeRTClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. @@ -313,7 +313,7 @@ Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, n The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently. -Neither a direct descriptor with `scope` nor a `@RemoteContext` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Context descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. +Neither a direct descriptor with `scope` nor a `@RemoteScope` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Remote Scope descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call. ```text root ctx.remote.goals.create(agentId, request) @@ -327,7 +327,7 @@ agentCtx.remote.goals.create(request) → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteContextApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. +The root `Context` merges only the direct `TypeRTClientRemote` surface. `AgentContext` replaces that property with the intersection of `TypeRTClientRemote` and `TypeRTRemoteScopeApi<'agent'>`, so scoped-only methods remain unavailable from root code. If a caller bypasses the type system and dynamically calls a scoped-only method from Root, the binder reports an explicit error. If the Client already has a Cordis service named `remote.`, or two contributions claim the same namespace and method incompatibly, mounting fails instead of overwriting the existing service. Generated Remote JS contains only descriptors, symbol keys, and codecs; it does not bundle Host Service implementations. The Client Remote Service creates real functions from that data, so the runtime does not depend on a JavaScript Proxy. A Proxy remains an implementation option but is not a source of types or reflection. @@ -337,7 +337,7 @@ Remote API is a consumer capability, not a synonym for Browser API. The shipped Remote DTS, Remote JS, `TypeRTClientRemote`, `InvocationDescriptor`, the Remote RPC data protocol, and Context binders must not depend on the DOM, Browser module loaders, or HTTP. Through Connection, the Browser Client encodes descriptor-materialized methods as `/api` RPC calls. -A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteContext`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. +A future TUI can join the same call abstraction without changing business decorators, Remote maps, or the shape of API calls. The TUI-visible API must still be generated exclusively from `@Remote` and `@RemoteScope`; sharing a process with the Host must not allow it to bypass Remote restrictions and expose Service methods directly. TUI runtime mounting, carriers, Agent Scope association, and SRC startup wiring are outside this phase. @@ -345,7 +345,7 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir ## SRC and LIB operating modes -SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteContext()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. +SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. @@ -365,7 +365,7 @@ Invocation resolves the descriptor, receiver, lookup providers, and Context prov An ordinary `@Remote` call retains the original Service instance as receiver. After lookups succeed, the Gateway calls the member identified by `implementation ?? method` with parameters in descriptor order, followed by the carrier signal when the descriptor declares cancellation. -A `@RemoteContext('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. +A `@RemoteScope('agent')` call first asks the Agent Context provider to resolve the wire identity, then reads the descriptor's service key from that Context and invokes the scoped receiver. The business method receives neither a hidden Context parameter nor an Agent ID. ```text ctx.typertGateway.invoke({ namespace, method, args, signal }) @@ -448,7 +448,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Package boundaries -- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Context, and descriptors. +- `@deepseek-ai/dsh-type-meta`: lightweight protocols for decorators, bindings, lookup, Remote Scope, and descriptors. - TypeRT generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - TypeRT runtime: separately stores the current environment's local reflection and imported Remote contributions. - `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. @@ -460,7 +460,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteContext('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index f1b7e5f9c6..0ce431b7cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -16,7 +16,7 @@ Host 与 Browser Client 使用独立的 TypeScript Program,因为两边会以 ## 决策 -业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteContext()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 +业务 Service 继承 `GatewayService`,并通过 `@Remote` 或 `@RemoteScope()` 声明可调用方法;已有其他基类的 Service 可以改用 `bindTypeRTGateway()` 暴露同一绑定。TypeRT 从 Host Program 生成 Host 本地反射产物和平台无关的 Remote 消费端投影;Client Program 继续独立生成自己的本地反射产物。 Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只暴露被 Remote decorator 标记的方法,并引用业务包唯一的公共类型符号;`.d.ts.map` 把消费端 API 方法导航回 Host 业务方法实现;`.js` 携带同一契约的 endpoint、参数、Context 和 Zod 信息。Browser Client 在 assembly 层把需要的 Remote JS 贡献集中挂到 Client Remote Service;该投影和 Remote 抽象保持平台无关,以便未来 TUI 复用。 @@ -64,7 +64,7 @@ export class GoalService extends GatewayService { `goals` 是传给 `super()` 的明确 Cordis service key,并默认作为 wire namespace。只有协议 namespace 确实需要与 service key 不同时,才通过第三个参数传入 `namespace` 选项。 -需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteContext()`。Context identity 不进入业务方法参数: +需要在某类隔离 Context 中查找 Service receiver 时使用 `@RemoteScope()`。Scope identity 不进入业务方法参数: ```text export class ScopedGoalService extends GatewayService { @@ -72,28 +72,28 @@ export class ScopedGoalService extends GatewayService { super(ctx, 'goals') } - @RemoteContext('agent', 'create') + @RemoteScope('agent', 'create') remoteExportCreate(request: CreateGoalRequest): Promise { // Runs against the goals service resolved from the Agent Context. } } ``` -同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteContext('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 +同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 TypeRT 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Context 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖轻量的 `@deepseek-ai/dsh-type-meta`。它提供 `GatewayService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteContext('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 只表达“该方法参与 Remote 契约”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `GatewayService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 SRC 运行时允许 decorator 在 `dsh-type-meta` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。 LIB 的严格方法发现、类型解析和 descriptor 生成由 TypeRT compiler 完成。它接受 `GatewayService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 -## Lookup 与 Remote Context 注册 +## Lookup 与 Remote Scope 注册 Gateway 不内置 Agent、Session 或其他业务对象分支。对象所属包同时提供静态声明和运行时 provider: @@ -115,7 +115,7 @@ ctx.typert.lookups.register('agent', { Agent、Session 等 lookup 对象只能各自占据一个顶层参数位置。普通 JSON request 可以作为另一个完整参数传入,但本设计不支持 `request.agent`、对象解构、对象数组、嵌套 lookup 或从任意复杂结构中搜索 ID。 -Remote Context 使用独立的 merge-extensible map 和 provider。Agent 包注册 `agent` Context provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 +Remote Scope 使用独立的 merge-extensible map 和 Context provider。Agent 包注册 `agent` provider,负责用 wire identity 找到 Agent Context,并从该 Context 解析 descriptor 指定的 service key;Gateway 不知道 Agent Context 的内部结构。 Client 侧也注册 `agent` Context binder。binder 只负责从一次调用所在的 Context 取得 `SessionId`;它不枚举 Scope,也不逐个复制方法。scoped namespace 由 Cordis Service tracker 自动 rebind 到当前 Agent Context。 @@ -267,7 +267,7 @@ interface TypeRTRemoteNamespaceMap { goals: TypeRTRemoteNamespace$676f616c73 } -interface TypeRTRemoteContextMap { +interface TypeRTRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, @@ -277,14 +277,14 @@ interface TypeRTRemoteContextMap { `TypeRTRemoteMap` 保留规范 endpoint 签名,供协议类型和反射使用。根 Remote 类型直接读取 `TypeRTRemoteNamespaceMap`,不通过 key-remapped mapped type 间接推导方法;TypeScript Language Service 无法把这种间接属性稳定导航到 declaration map。namespace interface 名由 namespace 的 UTF-8 bytes 编成 hex,`goals` 因而稳定得到 `TypeRTRemoteNamespace$676f616c73`。不同 package 对同一 namespace 生成同名 interface,依靠 module augmentation 合并各自方法,且 `TypeRTRemoteNamespaceMap.goals` 始终引用同一类型。 -TypeRT 把 `TypeRTRemoteContextMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: +TypeRT 把 `TypeRTRemoteScopeMap` 按 Context key 投影到专用 Scope 类型。最终编程界面保持: ```text ctx.remote.goals.create(agentId, request) agentCtx.remote.goals.create(request) ``` -Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteContext('agent')` 方法也省略独立的 Context identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 +Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteScope('agent')` 方法也省略独立的 Scope identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 `TypeRTClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 @@ -313,7 +313,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依 Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。 -带 `scope` 的 direct descriptor 和 `@RemoteContext` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Context descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 +带 `scope` 的 direct descriptor 和 `@RemoteScope` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Remote Scope descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。 ```text root ctx.remote.goals.create(agentId, request) @@ -327,7 +327,7 @@ agentCtx.remote.goals.create(request) → ctx.connection.rpc.call('/api', 'goals/create', { args }) ``` -根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteContextApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 +根 `Context` 只 merge direct `TypeRTClientRemote` surface;`AgentContext` 把该属性替换为 `TypeRTClientRemote` 与 `TypeRTRemoteScopeApi<'agent'>` 的交叉,因而 scoped-only 方法不会暴露给 root 代码。若调用方绕过类型从 Root 动态调用 scoped-only 方法,binder 明确报错。若 Client 已有名为 `remote.` 的 Cordis service,或两个 contribution 冲突占用同一 namespace/method,mount 直接失败,不覆盖现有服务。 生成的 Remote JS 只包含 descriptor、symbol key 和 codec,不打包 Host Service 实现。Client Remote Service 据此创建真实函数,因此运行时不依赖 JavaScript Proxy;Proxy 可以作为实现选择,但不会成为类型或反射来源。 @@ -337,7 +337,7 @@ Remote API 是消费端能力,不等同于 Browser API。已交付的运行时 Remote DTS、Remote JS、`TypeRTClientRemote`、`InvocationDescriptor`、Remote RPC 数据协议和 Context binder 不得依赖 DOM、Browser module loader 或 HTTP。Browser Client 通过 Connection 把 descriptor 实体化的方法编码为 `/api` RPC 调用。 -未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteContext` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 +未来 TUI 可以在不改变业务 decorator、Remote maps 和 API 调用形状的前提下接入同一调用抽象。届时 TUI 可见的 API 仍只能由 `@Remote` 和 `@RemoteScope` 生成,不能因为它与 Host 同进程就绕过 Remote 限制直接暴露 Service 方法。 TUI 的 runtime 挂载、carrier、Agent Scope 关联和 SRC 启动接线均不属于本期实现。 @@ -345,7 +345,7 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 ## SRC 与 LIB 运行模式 -SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 +SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 @@ -365,7 +365,7 @@ Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 普通 `@Remote` 调用保留原始 Service 实例作为 receiver。lookup 成功后,Gateway 按 descriptor 的参数顺序调用 `implementation ?? method` 指定的成员;若 descriptor 声明取消,则在这些参数之后追加 carrier signal。 -`@RemoteContext('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 +`@RemoteScope('agent')` 调用先由 Agent Context provider 解析 wire identity,再从该 Context 读取 descriptor 的 service key 并调用 scoped receiver。业务方法不会收到隐藏 Context 参数或 Agent ID。 ```text ctx.typertGateway.invoke({ namespace, method, args, signal }) @@ -448,7 +448,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 包边界 -- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Context 和 descriptor 协议。 +- `@deepseek-ai/dsh-type-meta`:轻量 decorator、binding、lookup、Remote Scope 和 descriptor 协议。 - TypeRT generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - TypeRT runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 - `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 @@ -460,7 +460,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index d07272c182..2a6ae0807b 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: 90aa661cc86a4f419e173560c55511c969182990 -api-gateway.zh.md: 6fcbb562b204e71d00833042ee0632bda0217940 +api-gateway.md: ba95d429dd0c9f9f354baf0063197cea6e3ecbf8 +api-gateway.zh.md: 4e42ebea7a5db19c7df23079050b9488679a3a23 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 90aa661cc8..ba95d429dd 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -6,17 +6,17 @@ This is the current-state reference for the TypeRT API Gateway. It describes how ## Programming model -Business services use `@Remote` or `@RemoteContext` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. +Business services use `@Remote` or `@RemoteScope` to select the methods exposed to the Client. Unmarked methods do not enter the generated Client types or runtime contributions and cannot be called through `ctx.remote`. `@Remote` denotes calling a Cordis service registered on the root Host Context. Complex Host objects cannot cross the wire directly; the business package must declare their association with a wire identity through `TypeRTLookupMap` and register a default resolution provider with `ctx.typert.lookups` at runtime. For example, an `Agent` parameter named `agent` in the Host signature produces an `agentId` wire field, and the Gateway resolves that id to a Host object before invoking the business method. Host composition can use `ctx.typert.lookups.configure()` to override the resolution policy for a lookup key without changing the parameter name, wire field, or canonical type symbol owned by the business package. -`@RemoteContext(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. +`@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. Services normally extend `GatewayService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. ```ts import type { Agent } from '@deepseek-ai/dsh-agent' -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Context } from 'cordis' export interface CreateGoalRequest { @@ -42,7 +42,7 @@ export class GoalService extends GatewayService { return this.create(agent, request) } - @RemoteContext('agent', 'current') + @RemoteScope('agent', 'current') currentForClient(): CreateGoalResult { return { accepted: true } } @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -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()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. 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. `@RemoteContext` generates only the scoped invocation interface. +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()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. 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 import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -101,7 +101,7 @@ Each contributing business package writes generated files to its own `lib/` dire | `typert.host.js` | Host Loader | Runtime reflection for the Host face, strict invocation descriptors, and schema registration values | | `typert.host.d.ts` | Host type system | Generated declarations for the Host face | | `typert.remote-client.js` | `api-remotes` | A mountable `TypeRTRemoteContribution` containing strict descriptors and runtime codecs | -| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteContextMap`, plus Client-safe type references | +| `typert.remote-client.d.ts` | Client type system | Declaration merges for `TypeRTRemoteNamespaceMap` and `TypeRTRemoteScopeMap`, plus Client-safe type references | | `typert.remote-client.d.ts.map` | Editor | Maps generated method properties back to Remote method declarations in the Host package | Business packages expose the Host Loader entry through `./typert` and the Host-for-Client entry through `./remote`. The generator also validates these package exports and published-file lists; it generates artifacts only for explicit contribution packages that provide the corresponding entry. @@ -126,7 +126,7 @@ Unloading a Client contribution removes its descriptors and concrete methods tog When the Host starts from source through `node --import tsx/esm`, it does not execute the TypeRT compiler plugin. Standard decorator initializers still record the method name and invocation mode in a module-private `WeakMap`, while `GatewayService` or `bindTypeRTGateway()` supplies the explicit service binding; the Gateway can therefore construct a weaker temporary descriptor without starting a `ts.Program`. -The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteContext` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. +The SRC fallback parses simple parameter names from the live function. When a parameter name matches the `parameter` of a registered lookup, such as `agent` or `session`, it uses the lookup's `agentId` or `sessionId` wire field and resolves the object on the Host; other parameters are checked only for cycle-free, JSON-safe data with no special prototype. `@RemoteScope` directly uses the wire field of a registered Host Context provider. SRC does not read TypeScript types, generate Zod schemas, infer optional parameters, or support destructuring, default values, rest parameters, or duplicate parameter names. SRC solves only dispatch for a Host process running from source. The Client does not discover decorators from the running Host, and the Client Remote refuses to mount SRC descriptors that lack strict codecs; its types, codecs, and Remote registration values always come from the most recently generated `lib/typert.remote-client.*` artifacts. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 6fcbb562b2..4e42ebea7a 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -6,17 +6,17 @@ ## 编程模型 -业务 Service 通过 `@Remote` 或 `@RemoteContext` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 +业务 Service 通过 `@Remote` 或 `@RemoteScope` 选择对 Client 开放的方法。未标记的方法不会进入生成的 Client 类型或运行时贡献,也不能通过 `ctx.remote` 调用。 `@Remote` 表示调用根 Host Context 中注册的 Cordis Service。复杂的 Host 对象不能直接跨 wire 传输;业务包必须通过 `TypeRTLookupMap` 声明它与 wire identity 的关联,并在运行时向 `ctx.typert.lookups` 注册默认解析提供方。例如 `Agent` 参数在 Host 签名中名为 `agent`,生成的 wire 字段为 `agentId`,Gateway 在调用业务方法前将 id 解析为 Host 对象。Host 组合可以用 `ctx.typert.lookups.configure()` 覆盖某个 lookup key 的解析策略,而不改变业务包拥有的参数名、wire 字段或规范类型 symbol。 -`@RemoteContext(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 +`@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得 Service 并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的 Service 可以改为声明 `readonly typertGateway = bindTypeRTGateway(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 ```ts import type { Agent } from '@deepseek-ai/dsh-agent' -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Context } from 'cordis' export interface CreateGoalRequest { @@ -42,7 +42,7 @@ export class GoalService extends GatewayService { return this.create(agent, request) } - @RemoteContext('agent', 'current') + @RemoteScope('agent', 'current') currentForClient(): CreateGoalResult { return { accepted: true } } @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteContext` 只生成作用域调用界面。 +Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -101,7 +101,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 | `typert.host.js` | Host Loader | Host face 的运行时反射、严格调用描述符和 schema 注册值 | | `typert.host.d.ts` | Host 类型系统 | Host face 的生成声明 | | `typert.remote-client.js` | `api-remotes` | 可挂载的 `TypeRTRemoteContribution`,包含严格描述符与运行时 codec | -| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteContextMap` 的声明合并及 Client-safe 类型引用 | +| `typert.remote-client.d.ts` | Client 类型系统 | `TypeRTRemoteNamespaceMap` 与 `TypeRTRemoteScopeMap` 的声明合并及 Client-safe 类型引用 | | `typert.remote-client.d.ts.map` | 编辑器 | 将生成的方法属性映射回 Host 包中的 Remote 方法声明 | 业务包通过 `./typert` 暴露 Host Loader 入口,通过 `./remote` 暴露 Host-for-Client 入口。生成器同时校验这些 package export 及发布文件清单;只有具备相应入口的显式贡献包才会生成产物。 @@ -126,7 +126,7 @@ Client 卸载一个贡献时会一起移除描述符和具体方法,中止其 Host 通过 `node --import tsx/esm` 从源码启动时不会执行 TypeRT 编译插件。标准 decorator 初始化器仍会把方法名和调用模式记录到模块私有 `WeakMap`,`GatewayService` 或 `bindTypeRTGateway()` 则提供显式 service binding;Gateway 因而可以在不启动 `ts.Program` 的情况下构造一个较弱的临时描述符。 -SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteContext` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 +SRC 回退从运行中函数解析简单参数名。参数名与某个已注册 lookup 的 `parameter` 相同,例如 `agent` 或 `session`,就使用其 `agentId` 或 `sessionId` wire 字段并在 Host 解析对象;其他参数只检查值是否为无循环、无特殊 prototype 的 JSON-safe 数据。`@RemoteScope` 直接使用已注册 Host Context provider 的 wire 字段。SRC 不读取 TypeScript 类型,不生成 Zod schema,不推断可选参数,也不支持解构、默认值、rest 或重复参数名。 SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Host 发现 decorator,Client Remote 也拒绝挂载缺少严格 codec 的 SRC 描述符;其类型、codec 和 Remote 注册值始终来自最近一次生成的 `lib/typert.remote-client.*`。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 933f204fa0..b66552b175 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: 37bc88c7c1cfedfbe1a93e08a4cbde833ac32372 -development.zh.md: a738e53cb3434d7930aa82107782a4c22aea1470 +development.md: b7ecab3536d739c105f11a640a07ea83a22f4398 +development.zh.md: 33ceba9f05c45c06acae7c83425a30c5e26ca433 diff --git a/docs/development.md b/docs/development.md index 37bc88c7c1..b7ecab3536 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ Host and client stay two aggregate programs because both sides declaration-merge 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). -Business services declare callable methods on the Host with `@Remote` or `@RemoteContext`; 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. +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. If a relevant local check consumes built package output, build once first: diff --git a/docs/development.zh.md b/docs/development.zh.md index a738e53cb3..33ceba9f05 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -62,7 +62,7 @@ host 与 client 保持两个聚合 program,是因为两侧在相同键下以 静态分析和测试通过 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)。 -业务 Service 在 Host 使用 `@Remote` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.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/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 3a9a0ba50d..3f4cd32e4d 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/gateway/README.md -README.md: e37359db71c1388667e9e61f538354711e90c0c1 -README.zh.md: 2054febb9a5423297c32b029b40a035062250aab +README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502 +README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index e37359db71..0e1a03d201 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -6,9 +6,9 @@ Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) -`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. +`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 2054febb9a..6b5ccff234 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -6,9 +6,9 @@ ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 216f2359e7..1383175e73 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -5,7 +5,7 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client import type { InvocationDescriptor, TypeRTContext, - TypeRTRemoteContextApi, + TypeRTRemoteScopeApi, TypeRTRemoteNamespace, } from '@deepseek-ai/dsh-type-meta' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -24,7 +24,7 @@ declare module '@deepseek-ai/dsh-type-meta' { ) => Promise<{ readonly ref: string }> } - interface TypeRTRemoteContextMap { + interface TypeRTRemoteScopeMap { 'fixture:goals/create': ( request: { readonly objective: string }, signal?: AbortSignal, @@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Omit & { readonly remote: TypeRTRemoteContextApi<'fixture'> } +type FixtureContext = Omit & { readonly remote: TypeRTRemoteScopeApi<'fixture'> } const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index d298116b82..4fa0ea80ad 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -8,7 +8,7 @@ import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserve import { bindTypeRTGateway, Remote, - RemoteContext, + RemoteScope, TypeRTLookupFailure, type InvocationDescriptor, type TypeRTContext, @@ -65,7 +65,7 @@ class GoalService extends Service { } } - @RemoteContext('gatewayFixture') + @RemoteScope('gatewayFixture') rename(request: { readonly title: string }): unknown { this.calls.push('rename') return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' } @@ -299,7 +299,7 @@ class ContextWireService extends Service { super(ctx, 'contextWire') } - @RemoteContext('gatewayFixture') + @RemoteScope('gatewayFixture') run(agentId: string): string { return agentId } @@ -389,7 +389,7 @@ describe('TypertGatewayService', () => { expect(service.lastSignal?.aborted).toBe(false) }) - it('resolves strict Remote Context identity without adding a business argument', async () => { + it('resolves strict Remote Scope identity without adding a business argument', async () => { const { ctx, service } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-scope' }) ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) @@ -432,7 +432,7 @@ describe('TypertGatewayService', () => { expect(service.calls).toEqual([]) }) - it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => { + it('derives SRC Remote Scope identity and preserves the scoped Proxy receiver', async () => { const { ctx } = await setup() const scoped = ctx.extend({ fixtureScope: 'agent-src' }) ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index 1154d10feb..25644d24ba 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,11 +18,11 @@ import { Context as CordisContext } from 'cordis' import type { Context, Fiber } from 'cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { TypeRTClientRemote, TypeRTRemoteContextApi } from '@deepseek-ai/dsh-type-meta' +import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ export type AgentContext = Omit & { - readonly remote: TypeRTClientRemote & TypeRTRemoteContextApi<'agent'> + readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'> } /** Context tag written by {@link createScope}. */ diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index bc5024a7d8..c5d89b3726 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1032,10 +1032,10 @@ class FaceAnalyzer { if (invocation.kind === 'context') { const context = this.contextDeclarations().get(invocation.context) if (context === undefined) { - this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`) + this.fail(method, `Remote Scope ${invocation.context} has no TypeRTContextMap entry`) } const wire = `${invocation.context}Id` - if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`) + if (wires.has(wire)) this.fail(method, `Remote Scope wire field ${wire} conflicts with a method parameter`) receiver = { kind: 'context', context: invocation.context, @@ -1200,18 +1200,18 @@ class FaceAnalyzer { } marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) - && this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) { + && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { if (expression.arguments.length < 1 || expression.arguments.length > 2) { - this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name') + this.fail(expression, 'RemoteScope() requires a Context key and optional exported method name') } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { - this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a string literal containing only RPC endpoint segment characters') + this.fail(expression.arguments[0] ?? expression, 'RemoteScope() key must be a string literal containing only RPC endpoint segment characters') } const exportArgument = expression.arguments[1] const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { - this.fail(exportArgument, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters') + this.fail(exportArgument, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') } marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { @@ -2529,7 +2529,7 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { ? decorator.expression.expression : decorator.expression const name = expressionName(expression) - if (name === 'Remote' || name === 'RemoteContext') return true + if (name === 'Remote' || name === 'RemoteScope') return true } } } diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index bb39959606..cbed0047c3 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -376,7 +376,7 @@ export class FaceModelEmitter { lines.push(' }') } if (scoped.length > 0) { - lines.push(' interface TypeRTRemoteContextMap {') + lines.push(' interface TypeRTRemoteScopeMap {') for (const invocation of scoped) { this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true) } diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index 4aa51ec433..e84d6fd142 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -1,4 +1,4 @@ -import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta' +import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' import type { Agent } from '@fixture/domain' import type { CreateGoalRequest, @@ -19,7 +19,7 @@ export class GoalService extends GatewayService { return { ref: `${agent.id}:${request.title}` } } - @RemoteContext('agent') + @RemoteScope('agent') rename(request: RenameGoalRequest): RenameGoalResult { return { renamed: request.title.length > 0 } } diff --git a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts index 5347a6b77e..707dc84ce9 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts @@ -11,7 +11,7 @@ declare module '@deepseek-ai/dsh-type-meta' { export interface TypeRTLookupMap {} export interface TypeRTContextMap {} export interface TypeRTRemoteMap {} - export interface TypeRTRemoteContextMap {} + export interface TypeRTRemoteScopeMap {} export type TypeRTRemoteNamespace = { [Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}` @@ -56,7 +56,7 @@ declare module '@deepseek-ai/dsh-type-meta' { context: ClassMethodDecoratorContext Result>, ) => void - export function RemoteContext(key: Extract, exportName?: string): + export function RemoteScope(key: Extract, exportName?: string): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index eaaf680cc6..0e62a56bf4 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -288,7 +288,7 @@ export interface BoxPayload { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source .replace(' @Remote\n', '') - .replace(" @RemoteContext('agent')\n", '')) + .replace(" @RemoteScope('agent')\n", '')) editFile(root, 'packages/remote/src/types.ts', source => `${source} /** @typert schema */ @@ -377,8 +377,8 @@ export interface ClientMarker { name: 'duplicate GatewayService field binding', edit: (source: string) => source .replace( - 'import { GatewayService, Remote, RemoteContext }', - 'import { GatewayService, Remote, RemoteContext, bindTypeRTGateway }', + 'import { GatewayService, Remote, RemoteScope }', + 'import { GatewayService, Remote, RemoteScope, bindTypeRTGateway }', ) .replace( 'export class GoalService extends GatewayService {', @@ -495,11 +495,11 @@ export interface ClientMarker { expect(() => analyzeRemote(root)).not.toThrow() }) - it('rejects a Remote Context without a static Context declaration', () => { + it('rejects a Remote Scope without a static Context declaration', () => { const root = copyFixture() - editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')")) + editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteScope('agent')", "@RemoteScope('missing')")) - expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/) + expect(() => analyzeRemote(root, false)).toThrow(/Remote Scope missing has no TypeRTContextMap entry/) }) it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => { @@ -579,7 +579,7 @@ function assertRemoteConsumerTypechecks( import remote from '@fixture/remote/remote' import type { TypeRTRemoteContribution, - TypeRTRemoteContextMap, + TypeRTRemoteScopeMap, TypeRTRemoteMap, TypeRTRemoteNamespaceMap, } from '@deepseek-ai/dsh-type-meta' @@ -587,8 +587,8 @@ import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types' const contribution: TypeRTRemoteContribution = remote declare const create: TypeRTRemoteMap['goals/create'] -declare const createScoped: TypeRTRemoteContextMap['agent:goals/create'] -declare const rename: TypeRTRemoteContextMap['agent:goals/rename'] +declare const createScoped: TypeRTRemoteScopeMap['agent:goals/create'] +declare const rename: TypeRTRemoteScopeMap['agent:goals/rename'] const created: Promise = create('agent-1', { title: 'ship' }) const cancellable: Promise = create('agent-1', { title: 'ship' }, new AbortController().signal) const createdScoped: Promise = createScoped({ title: 'ship' }) diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 6c21127e54..a61602c07b 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/type-meta/README.md -README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c -README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec +README.md: 9bd475f8973ec54756fe0e63d5b7fa485381697d +README.zh.md: 10a6309bc47001d572abb3dd3f794ebfcf6252e8 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index a76169742c..9bd475f897 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -7,7 +7,7 @@ Compiler-independent declarations shared by business packages, generated TypeRT ## Remote declarations - `@Remote` marks a public instance method for direct invocation on its registered Cordis Service. -- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. +- `@RemoteScope(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind. - `GatewayService` binds the Cordis key passed to `super(ctx, serviceKey, options?)` to the same default wire namespace. - `bindTypeRTGateway(this, serviceKey, options?)` provides the same visible, frozen binding for a Service that cannot inherit from `GatewayService`. - `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback. @@ -18,7 +18,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the ## TypeRT protocol -Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. +Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteScopeMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client Remote. Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 6f2d2fd6e2..10a6309bc4 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -7,7 +7,7 @@ ## Remote 声明 - `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。 -- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 +- `@RemoteScope(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。 - `GatewayService` 将 `super(ctx, serviceKey, options?)` 接收的 Cordis key 同时绑定为默认 wire namespace。 - `bindTypeRTGateway(this, serviceKey, options?)` 为无法继承 `GatewayService` 的 Service 提供同样可见且冻结的绑定。 - `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。 @@ -18,7 +18,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 ## TypeRT 协议 -业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 +业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteScopeMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client Remote 使用的共享运行时形式。 查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 774c6d3b32..1375d7872b 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -60,9 +60,9 @@ export type { TypeRTLookupResolver, TypeRTLookupRegistry, TypeRTLookupWire, - TypeRTRemoteContextApi, - TypeRTRemoteContextMap, - TypeRTRemoteContextNamespace, + TypeRTRemoteScopeApi, + TypeRTRemoteScopeMap, + TypeRTRemoteScopeNamespace, TypeRTRemoteContribution, TypeRTRemoteMap, TypeRTRemoteNamespace, @@ -191,16 +191,16 @@ export function Remote( } /** - * Create a decorator for a method resolved from one scoped Remote Context. - * @param key - merge-declared Context key. + * Create a decorator for a method resolved from one Remote Scope. + * @param key - scope key declared through the Context map. * @param exportName - optional Remote export name; defaults to the method name. * @returns a standard method decorator that records only private module state. */ -export function RemoteContext( +export function RemoteScope( key: Extract, exportName?: string, ): RemoteMethodDecorator { - validateName('Context key', key) + validateName('Scope key', key) if (exportName !== undefined) validateName('Remote export name', exportName) return function ( _method: (this: This, ...args: Args) => Result, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 5e7c20cd7c..c1d6b3dcf9 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -40,7 +40,7 @@ export interface TypeRTContextMap {} export interface TypeRTRemoteMap {} /** Merge-extensible scoped Remote method signatures generated for consumers. */ -export interface TypeRTRemoteContextMap {} +export interface TypeRTRemoteScopeMap {} /** * Resolve one direct Remote namespace from the generated flat endpoint map. @@ -57,24 +57,24 @@ export type TypeRTRemoteNamespace = { * The calling Cordis Context supplies the concrete identity at runtime. * @template Namespace - wire namespace between the Context prefix and method. */ -export type TypeRTRemoteContextNamespace< +export type TypeRTRemoteScopeNamespace< Namespace extends string, ContextKey extends string = string, > = { - [Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` + [Endpoint in keyof TypeRTRemoteScopeMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}` ? Method - : never]: TypeRTRemoteContextMap[Endpoint] + : never]: TypeRTRemoteScopeMap[Endpoint] } -type TypeRTRemoteContextNamespaceKey< +type TypeRTRemoteScopeNamespaceKey< ContextKey extends string, - Endpoint = keyof TypeRTRemoteContextMap, + Endpoint = keyof TypeRTRemoteScopeMap, > = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never /** Generated scoped Remote namespaces available to one Context kind. */ -export type TypeRTRemoteContextApi = { - [Namespace in TypeRTRemoteContextNamespaceKey]: - TypeRTRemoteContextNamespace +export type TypeRTRemoteScopeApi = { + [Namespace in TypeRTRemoteScopeNamespaceKey]: + TypeRTRemoteScopeNamespace } /** Merge-extensible direct namespace surface generated for Client Remote services. */ @@ -227,7 +227,7 @@ export interface TypeRTLookupDefinition { readonly wireTypeSymbol: string } -/** Host resolver for one scoped Remote Context kind. */ +/** Host resolver for one scoped Remote kind. */ export interface TypeRTHostContextProvider { /** Wire field carrying the Context identity. */ readonly wire: string diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index b13a80796d..14eec6610d 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -2,7 +2,7 @@ import { Context } from 'cordis' import { GatewayService, Remote, - RemoteContext, + RemoteScope, remoteMethods, } from '@deepseek-ai/dsh-type-meta' @@ -16,7 +16,7 @@ class Goals extends GatewayService { return value } - @RemoteContext('agent') + @RemoteScope('agent') scoped(value: string): string { return value } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index b84b76300c..bfe99630b9 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -6,7 +6,7 @@ import { bindTypeRTGateway, GatewayService, Remote, - RemoteContext, + RemoteScope, remoteMethods, type TypeRTContext, } from '@deepseek-ai/dsh-type-meta' @@ -29,7 +29,7 @@ describe('type-meta Remote declarations', () => { return value } - @RemoteContext('metaFixture') + @RemoteScope('metaFixture') scoped(value: string): string { return value } @@ -84,7 +84,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown, methodContext('create', initializers), ) - RemoteContext('metaFixture')( + RemoteScope('metaFixture')( Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown, methodContext('scoped', initializers), ) @@ -141,7 +141,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, methodContext('run', initializers), ) - RemoteContext('metaFixture', 'inspect')( + RemoteScope('metaFixture', 'inspect')( Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown, methodContext('scoped', initializers), ) @@ -166,8 +166,8 @@ describe('type-meta Remote declarations', () => { expect(() => Remote('bad name')).toThrow('export name') expect(() => Remote('.')).toThrow('export name') expect(() => Remote('..')).toThrow('export name') - expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') - expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') + expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') + expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') for (const context of [ { ...methodContext('run', []), private: true }, @@ -195,7 +195,7 @@ describe('type-meta Remote declarations', () => { Reflect.get(Service.prototype, 'run'), methodContext('run', conflicting), ) - RemoteContext('metaFixture')( + RemoteScope('metaFixture')( Reflect.get(Service.prototype, 'run'), methodContext('run', conflicting), ) From 8bbbb6fe59d71e52f0aeefcd1c95c7bd84e55f91 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:23:47 +0800 Subject: [PATCH 67/88] fix(api-gateway): compose scoped remote fixture types --- packages/api/gateway/tests/client.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 1383175e73..d253c38acc 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypeRTClientRemote, TypeRTContext, TypeRTRemoteScopeApi, TypeRTRemoteNamespace, @@ -38,7 +39,9 @@ declare module '@deepseek-ai/dsh-type-meta' { } -type FixtureContext = Omit & { readonly remote: TypeRTRemoteScopeApi<'fixture'> } +type FixtureContext = Omit & { + readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'> +} const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) From 55ccfb5a48ab9d50b7953119036117c50057d650 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:44:47 +0800 Subject: [PATCH 68/88] fix(api): preserve dynamic defaults after rebase --- packages/api/remotes/src/agent-lookup.ts | 6 +++--- packages/host/apiproxy/tests/api-proxy-cold.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index eb54ea9b0b..71d7a76379 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -20,8 +20,8 @@ export type ApiRemoteAgentResult = /** Resume configuration supplied by the owning Host composition. */ export interface ApiRemoteAgentOptions { - /** Per-Agent defaults used when a cold identity must resume. */ - readonly agentOptions?: AgentOptions + /** Read the per-Agent defaults when a cold identity must resume. */ + readonly agentOptions?: () => AgentOptions /** Host-specific Agent-scope composition completed before publication. */ readonly setup?: AgentSetup } @@ -144,7 +144,7 @@ export function createApiRemoteAgentResolver( } const handle = await ctx.agents.resume({ resumeSessionId: sessionId, - ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions }, + ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() }, ...options.setup === undefined ? {} : { setup: options.setup }, }) return handle.agent diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index e5e137f0c4..8e79c641a8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) From 5c2625c26eb6affb3732be713823ec655dabf5a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:02:12 +0800 Subject: [PATCH 69/88] docs(typert): align client remote type mapping --- scripts/type-equiv.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ecb13f167c..095fa25b4f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1552,7 +1552,7 @@ }, { "doc": "docs/core-data-structures/typert.md", - "symbol": "TypeRTClientApi", + "symbol": "TypeRTClientRemote", "source": "packages/typert/type-meta/src/types.ts" } ] From 14ea7e134d0fe90b54218b651da7c00ca4e89a1f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:02:12 +0800 Subject: [PATCH 70/88] fix(api-remotes): await namespace assembly startup --- packages/api/remotes/src/client/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ebd342300e..be92b02d77 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -20,7 +20,8 @@ export const inject = ['remote'] /** * Mount the Host capabilities explicitly selected for this Client assembly. * @param ctx - Client Cordis root carrying the typed API service. + * @returns disposer after every selected Remote namespace is ready. */ -export function apply(ctx: Context): Promise<() => Promise> { - return ctx.remote.$mount(goalsRemote) +export async function apply(ctx: Context): Promise<() => Promise> { + return await ctx.remote.$mount(goalsRemote) } From 8b51a1e95c4bc87d69ac5c060ec390ea571b08f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:11:42 +0800 Subject: [PATCH 71/88] test(api-gateway): cover namespace rollback paths --- packages/api/gateway/src/client/index.ts | 58 +++++++++-------------- packages/api/gateway/tests/client.spec.ts | 40 ++++++++++++++++ 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index d0429339c8..e49e9e5822 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -194,7 +194,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('direct', descriptor.method, token)) return + namespace.service.remove('direct', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -212,7 +212,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { throw error } return async () => { - if (!namespace.service.remove('scoped', descriptor.method, token)) return + namespace.service.remove('scoped', descriptor.method, token) await this.disposeNamespace(descriptor.namespace, namespace) } } @@ -390,50 +390,36 @@ class RemoteNamespaceService extends Service { let record = this.methods.get(method) const fresh = record === undefined record ??= {} - if (record[kind] !== undefined) { - throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`) - } - try { - if (fresh) { - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { - const callerCtx = this.ctx - const current = this.methods.get(method) - const direct = current?.direct - const scoped = current?.scoped - return (...args: unknown[]) => { - return this.invokeRemote(direct, scoped, callerCtx, args) - } - }, - }) - this.methods.set(method, record) - } - if (kind === 'direct') record.direct = value - else record.scoped = value as ScopedMethod - } catch (error) { - if (kind === 'direct') delete record.direct - else delete record.scoped - if (fresh) { - this.methods.delete(method) - Reflect.deleteProperty(this, method) - } - throw error + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + const callerCtx = this.ctx + const current = this.methods.get(method) + const direct = current?.direct + const scoped = current?.scoped + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args) + } + }, + }) + this.methods.set(method, record) } + if (kind === 'direct') record.direct = value + else record.scoped = value as ScopedMethod } - remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean { + remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void { const record = this.methods.get(method) const current = record?.[kind] /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ - if (record === undefined || current?.token !== token) return false + if (record === undefined || current?.token !== token) return if (kind === 'direct') delete record.direct else delete record.scoped - if (record.direct !== undefined || record.scoped !== undefined) return true + if (record.direct !== undefined || record.scoped !== undefined) return this.methods.delete(method) Reflect.deleteProperty(this, method) - return true } } diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index d253c38acc..01bb9c53b7 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -316,6 +316,32 @@ describe('Client TypeRT API', () => { await retry() }) + it('rolls back a direct projection when its scoped projection fails to install', async () => { + const ctx = await bench(vi.fn()) + const disposeContext = await ctx.remote.$mount({ + package: '@fixture/context-anchor', + descriptors: [contextDescriptor()], + }) + const namespace = ctx.get('remote.goals') as unknown as { + installScoped: (...args: unknown[]) => void + readonly create?: unknown + } + const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => { + throw new Error('fixture scoped projection failure') + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/direct-projection-failure', + descriptors: [directDescriptor()], + })).rejects.toThrow('fixture scoped projection failure') + } finally { + installScoped.mockRestore() + } + + expect(namespace.create).toBeUndefined() + await disposeContext() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -406,6 +432,20 @@ describe('Client TypeRT API', () => { expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) + it('rejects a method obtained from a withdrawn namespace getter', async () => { + const ctx = await bench(vi.fn()) + const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + const namespace = ctx.get('remote.goals') as unknown as object + const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get + + await dispose() + + expect(getter).toBeTypeOf('function') + const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise + await expect(withdrawn('agent-1', { objective: 'ship' })) + .rejects.toThrow('Remote method is no longer mounted') + }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) From 00a559bf2b97caf8f15b2e8303cbb17b061692de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:16:29 +0800 Subject: [PATCH 72/88] test(api-gateway): assert withdrawn method failure --- packages/api/gateway/tests/client.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 01bb9c53b7..2284fa662c 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -432,7 +432,7 @@ describe('Client TypeRT API', () => { expect((ctx.remote as unknown as Record).goals).toBeUndefined() }) - it('rejects a method obtained from a withdrawn namespace getter', async () => { + it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) const namespace = ctx.get('remote.goals') as unknown as object @@ -442,8 +442,8 @@ describe('Client TypeRT API', () => { expect(getter).toBeTypeOf('function') const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise - await expect(withdrawn('agent-1', { objective: 'ship' })) - .rejects.toThrow('Remote method is no longer mounted') + expect(() => withdrawn('agent-1', { objective: 'ship' })) + .toThrow('Remote method is no longer mounted') }) it('preserves a __proto__ wire parameter as an own named argument', async () => { From ddd43ec3718eb4d97e1db072940e35073cad4d6c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:29:39 +0800 Subject: [PATCH 73/88] test(api-gateway): repair CI fixtures --- packages/api/gateway/tests/client.spec.ts | 6 +++--- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 2284fa662c..641ea81ebc 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -436,12 +436,12 @@ describe('Client TypeRT API', () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) const namespace = ctx.get('remote.goals') as unknown as object - const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get + const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace) await dispose() - expect(getter).toBeTypeOf('function') - const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise + expect(getWithdrawn).toBeTypeOf('function') + const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise expect(() => withdrawn('agent-1', { objective: 'ship' })) .toThrow('Remote method is no longer mounted') }) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 794cf18f49..91509f3267 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 `@RemoteContext`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions and mounts concrete methods on `ctx.api` or the corresponding scoped Context. 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 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" }, { "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` 或 `@RemoteContext` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并将具体方法挂载到 `ctx.api` 或对应的作用域 Context。两侧的生成产物、装配关系、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仓库类型检查会执行全仓 `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" }, { "role": "user", From 71ebeaa55985a4033c86793f6c91fc0fe65cf8b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:43:34 +0800 Subject: [PATCH 74/88] fix(client-runtime): localize remote namespace dependency --- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 4 +++- docs/api-gateway.zh.md | 4 +++- packages/client/runtime/src/client/index.ts | 4 ++-- packages/client/runtime/tests/client-apply.spec.ts | 1 - packages/client/runtime/tests/wire-events.spec.ts | 1 - 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 2a6ae0807b..074644ff3e 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: ba95d429dd0c9f9f354baf0063197cea6e3ecbf8 -api-gateway.zh.md: 4e42ebea7a5db19c7df23079050b9488679a3a23 +api-gateway.md: 33dfb30c9da25e46b660a3fa54ef37f587cbda08 +api-gateway.zh.md: 633eb10c0f2f065ecf27545813cc17d79f391865 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index ba95d429dd..33dfb30c9d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. -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()`, consumers inject both `remote` and the namespace Service they call, and the namespace unloads after its last method is withdrawn. 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. +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 import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -63,6 +63,8 @@ import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +export const inject = ['remote', 'remote.goals'] + declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4e42ebea7a..633eb10c0f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -55,7 +55,7 @@ export class GoalService extends GatewayService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 -Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子 Service;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,消费方同时注入 `remote` 与所调用的 namespace Service,最后一个方法撤回后该 namespace 随即卸载。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypeRTContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用界面。 +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 import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -63,6 +63,8 @@ import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +export const inject = ['remote', 'remote.goals'] + declare const ctx: Context declare const agentCtx: AgentContext declare const agentId: SessionId diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b772e315a3..5a1677df96 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -179,8 +179,8 @@ declare module 'cordis' { } } -/** Required services: the Remote root and Goal namespace, wire handle, and Client TypeRT registry. */ -export const inject = ['remote', 'remote.goals', 'connection', 'typert'] +/** Required services: the Remote root, wire handle, and Client TypeRT registry. */ +export const inject = ['remote', 'connection', 'typert'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index e9b387fb00..b700c4c066 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -39,7 +39,6 @@ async function mount(): Promise { } ctx.reflect.provide('connection', handle) ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 703c5b1728..dfafcd07aa 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -33,7 +33,6 @@ async function mount(): Promise { } ctx.reflect.provide('connection', handle) ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.goals', {}) await ctx.plugin(RuntimeClient).await() return bench } 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 75/88] 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 76/88] 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 77/88] 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 38c373af65923abcd8fff7c200f5a64ca93b617b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:41:09 +0800 Subject: [PATCH 78/88] fix(ci): keep issue policy test discovery focused --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9698d0d2fa..81cb40acad 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:issue-management": "node --test .github/issue-management/policy.test.mjs", + "test:issue-management": "node .github/issue-management/policy.test.mjs", "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", From db0133b4c713d7e40f79e82db1e1480b997f27df Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:52:19 +0800 Subject: [PATCH 79/88] docs(notes): archive low-value records --- ...-native-typescript-source-launch.i18n.yaml | 6 ++ ...-28-dsh-native-typescript-source-launch.md | 1 + ...-dsh-native-typescript-source-launch.zh.md | 1 + ...tion-composer-rows-do-not-shrink.i18n.yaml | 6 ++ ...27-question-composer-rows-do-not-shrink.md | 1 + ...question-composer-rows-do-not-shrink.zh.md | 1 + ...28-web-conversation-polish-sweep.i18n.yaml | 6 ++ ...026-07-28-web-conversation-polish-sweep.md | 1 + ...-07-28-web-conversation-polish-sweep.zh.md | 1 + ...07-30-web-details-default-closed.i18n.yaml | 6 ++ .../2026-07-30-web-details-default-closed.md | 1 + ...026-07-30-web-details-default-closed.zh.md | 1 + ...isible-while-blank-session-opens.i18n.yaml | 6 ++ ...-hero-visible-while-blank-session-opens.md | 1 + ...ro-visible-while-blank-session-opens.zh.md | 1 + ...versation-column-one-axis-scroll.i18n.yaml | 6 ++ ...-04-conversation-column-one-axis-scroll.md | 1 + ...-conversation-column-one-axis-scroll.zh.md | 5 +- .../2026-07-22-docked-web-goal-bar.i18n.yaml | 6 ++ .../feature/2026-07-22-docked-web-goal-bar.md | 1 + .../2026-07-22-docked-web-goal-bar.zh.md | 1 + ...b-message-icon-actions-and-clock.i18n.yaml | 6 ++ ...7-29-web-message-icon-actions-and-clock.md | 1 + ...9-web-message-icon-actions-and-clock.zh.md | 1 + .../2026-07-30-dsh-dump-config.i18n.yaml | 6 ++ .../feature/2026-07-30-dsh-dump-config.md | 1 + .../feature/2026-07-30-dsh-dump-config.zh.md | 1 + ...-composer-stats-and-input-polish.i18n.yaml | 6 ++ ...-30-web-composer-stats-and-input-polish.md | 1 + ...-web-composer-stats-and-input-polish.zh.md | 1 + ...web-context-injection-disclosure.i18n.yaml | 6 ++ ...-07-30-web-context-injection-disclosure.md | 1 + ...-30-web-context-injection-disclosure.zh.md | 1 + ...2026-07-31-hover-card-click-copy.i18n.yaml | 6 ++ .../2026-07-31-hover-card-click-copy.md | 1 + .../2026-07-31-hover-card-click-copy.zh.md | 1 + .../2026-07-31-web-cards-toolrow.i18n.yaml | 6 ++ .../feature/2026-07-31-web-cards-toolrow.md | 1 + .../2026-07-31-web-cards-toolrow.zh.md | 1 + .agents/notes/archived/manifest.json | 59 ++++++++++++++++++- ...6-06-20-generated-cordis-catalog.i18n.yaml | 6 ++ .../2026-06-20-generated-cordis-catalog.md | 1 + .../2026-06-20-generated-cordis-catalog.zh.md | 1 + ...ntsource-parser-for-deepseek-sse.i18n.yaml | 6 ++ ...-26-eventsource-parser-for-deepseek-sse.md | 1 + ...-eventsource-parser-for-deepseek-sse.zh.md | 1 + ...ndown-for-tool-web-html-markdown.i18n.yaml | 6 ++ ...-26-turndown-for-tool-web-html-markdown.md | 1 + ...-turndown-for-tool-web-html-markdown.zh.md | 1 + ...ebar-resize-without-visible-pill.i18n.yaml | 6 ++ ...-30-sidebar-resize-without-visible-pill.md | 1 + ...-sidebar-resize-without-visible-pill.zh.md | 1 + ...eer-entry-or-interjection-chrome.i18n.yaml | 6 ++ ...i-no-steer-entry-or-interjection-chrome.md | 1 + ...o-steer-entry-or-interjection-chrome.zh.md | 1 + ...eca-for-test-subprocess-plumbing.i18n.yaml | 6 ++ ...7-26-execa-for-test-subprocess-plumbing.md | 1 + ...6-execa-for-test-subprocess-plumbing.zh.md | 1 + .../2026-06-13-twin-llm-adapters.i18n.yaml | 4 +- .../2026-06-13-twin-llm-adapters.md | 2 +- .../2026-06-13-twin-llm-adapters.zh.md | 2 +- ...-native-typescript-source-launch.i18n.yaml | 6 -- ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 4 +- .../2026-07-29-dsh-source-launch-tsx-esm.md | 4 +- ...2026-07-29-dsh-source-launch-tsx-esm.zh.md | 4 +- ...30-session-end-seed-log-boundary.i18n.yaml | 4 +- ...026-07-30-session-end-seed-log-boundary.md | 2 +- ...-07-30-session-end-seed-log-boundary.zh.md | 2 +- ...tion-composer-rows-do-not-shrink.i18n.yaml | 6 -- ...28-web-conversation-polish-sweep.i18n.yaml | 6 -- ...29-web-details-session-lifecycle.i18n.yaml | 4 +- ...026-07-29-web-details-session-lifecycle.md | 2 +- ...-07-29-web-details-session-lifecycle.zh.md | 2 +- ...07-30-web-details-default-closed.i18n.yaml | 6 -- ...isible-while-blank-session-opens.i18n.yaml | 6 -- ...versation-column-one-axis-scroll.i18n.yaml | 6 -- ...actions-require-a-completed-turn.i18n.yaml | 4 +- ...n-tail-actions-require-a-completed-turn.md | 2 +- ...ail-actions-require-a-completed-turn.zh.md | 2 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- .../2026-07-22-docked-web-goal-bar.i18n.yaml | 6 -- ...b-message-icon-actions-and-clock.i18n.yaml | 6 -- .../2026-07-30-dsh-dump-config.i18n.yaml | 6 -- ...-composer-stats-and-input-polish.i18n.yaml | 6 -- ...web-context-injection-disclosure.i18n.yaml | 6 -- ...2026-07-31-hover-card-click-copy.i18n.yaml | 6 -- .../2026-07-31-web-cards-toolrow.i18n.yaml | 6 -- ...b-context-source-and-steer-marks.i18n.yaml | 4 +- ...8-04-web-context-source-and-steer-marks.md | 4 +- ...4-web-context-source-and-steer-marks.zh.md | 4 +- ...-20-core-data-structures-catalog.i18n.yaml | 4 +- ...2026-06-20-core-data-structures-catalog.md | 4 +- ...6-06-20-core-data-structures-catalog.zh.md | 4 +- ...6-06-20-generated-cordis-catalog.i18n.yaml | 6 -- ...ntsource-parser-for-deepseek-sse.i18n.yaml | 6 -- ...ndown-for-tool-web-html-markdown.i18n.yaml | 6 -- ...ebar-resize-without-visible-pill.i18n.yaml | 6 -- ...eer-entry-or-interjection-chrome.i18n.yaml | 6 -- ...3-explicit-config-dsh-entrypoint.i18n.yaml | 4 +- ...26-08-03-explicit-config-dsh-entrypoint.md | 2 +- ...08-03-explicit-config-dsh-entrypoint.zh.md | 2 +- ...eca-for-test-subprocess-plumbing.i18n.yaml | 6 -- ...-29-session-resumed-log-boundary.i18n.yaml | 6 -- ...2026-07-29-session-resumed-log-boundary.md | 51 ---------------- ...6-07-29-session-resumed-log-boundary.zh.md | 51 ---------------- ...nimplemented-subagent-vocabulary.i18n.yaml | 6 -- ...prune-unimplemented-subagent-vocabulary.md | 39 ------------ ...ne-unimplemented-subagent-vocabulary.zh.md | 39 ------------ ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 4 +- ...-dependency-swaps-rejected-by-nih-audit.md | 4 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/web/tool-web/README.i18n.yaml | 4 +- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/README.zh.md | 2 +- 122 files changed, 272 insertions(+), 369 deletions(-) create mode 100644 .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml rename .agents/notes/{implemented => archived}/architecture/2026-07-28-dsh-native-typescript-source-launch.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-28-web-conversation-polish-sweep.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-30-web-details-default-closed.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-30-web-details-default-closed.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md (99%) create mode 100644 .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml rename .agents/notes/{implemented => archived}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md (97%) create mode 100644 .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-22-docked-web-goal-bar.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-22-docked-web-goal-bar.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-29-web-message-icon-actions-and-clock.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-30-dsh-dump-config.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-30-dsh-dump-config.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-30-dsh-dump-config.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-composer-stats-and-input-polish.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-context-injection-disclosure.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-30-web-context-injection-disclosure.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-31-hover-card-click-copy.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-31-hover-card-click-copy.zh.md (99%) create mode 100644 .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-07-31-web-cards-toolrow.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-07-31-web-cards-toolrow.zh.md (99%) create mode 100644 .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.i18n.yaml rename .agents/notes/{implemented => archived}/process/2026-06-20-generated-cordis-catalog.md (99%) rename .agents/notes/{implemented => archived}/process/2026-06-20-generated-cordis-catalog.zh.md (99%) create mode 100644 .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md (99%) create mode 100644 .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md (99%) create mode 100644 .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-30-sidebar-resize-without-visible-pill.md (98%) rename .agents/notes/{implemented => archived}/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md (98%) create mode 100644 .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md (99%) create mode 100644 .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml rename .agents/notes/{implemented => archived}/testing/2026-07-26-execa-for-test-subprocess-plumbing.md (99%) rename .agents/notes/{implemented => archived}/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md (99%) delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml delete mode 100644 .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml delete mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml delete mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md delete mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md delete mode 100644 .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml delete mode 100644 .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md delete mode 100644 .agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md diff --git a/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml new file mode 100644 index 0000000000..c0fcdd30f9 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.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/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md +2026-07-28-dsh-native-typescript-source-launch.md: b5e8ed2a18bb0cbb3ab54cf5ed4a427efaa9dfeb +2026-07-28-dsh-native-typescript-source-launch.zh.md: 05a4f92d2d3be553bb9ca363bf08e3ebf8e3f5b8 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md rename to .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md index 773f831ec2..b5e8ed2a18 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +++ b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -1,6 +1,7 @@ # Agent Note: Native TypeScript source launch for dsh Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md rename to .agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 0e40a7e32b..05a4f92d2d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/archived/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -1,6 +1,7 @@ # Agent Note: dsh 原生 TypeScript 源码启动 Status: implemented +Archived: 2026-08-07 [English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml new file mode 100644 index 0000000000..3520cf4974 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.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/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +2026-07-27-question-composer-rows-do-not-shrink.md: 47e581a23caaeb9368b075fa84a01d2bc945ab46 +2026-07-27-question-composer-rows-do-not-shrink.zh.md: a1b72cab41c51c268540dcec60e85a0edf2fe9bc diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md rename to .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md index 2e0e9b9ca6..47e581a23c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -1,6 +1,7 @@ # Agent Note: Question-composer option rows are scroll content, not the slack absorber Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-27-question-composer-rows-do-not-shrink.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index 73e3e7614c..a1b72cab41 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -1,6 +1,7 @@ # Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方 Status: implemented +Archived: 2026-08-07 [English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml new file mode 100644 index 0000000000..e77beba866 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.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/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md +2026-07-28-web-conversation-polish-sweep.md: 338b687c041c585c1d490fdad9b8bbcf88fc3912 +2026-07-28-web-conversation-polish-sweep.zh.md: 166662070b67837b9f4755e6457578f74af771ed diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md rename to .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md index cae52217d6..338b687c04 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md +++ b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.md @@ -1,6 +1,7 @@ # Agent Note: Web conversation UI polish sweep Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md rename to .agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md index 0f352f066d..166662070b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 对话 UI 视觉优化 Status: implemented +Archived: 2026-08-07 [English](2026-07-28-web-conversation-polish-sweep.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml new file mode 100644 index 0000000000..286ed2a62e --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.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/archived/bug-fix/2026-07-30-web-details-default-closed.md +2026-07-30-web-details-default-closed.md: e4271917b998c9a916d8c67b30627022587547b8 +2026-07-30-web-details-default-closed.zh.md: 3b9432067c944ea604e039ee1b305a927a1c206e diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.md b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.md rename to .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md index 658b6fc2c1..e4271917b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.md +++ b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.md @@ -1,6 +1,7 @@ # Agent Note: Web details default closed Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-web-details-default-closed.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.zh.md b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.zh.md rename to .agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.zh.md index 5a1d0e4713..3b9432067c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-30-web-details-default-closed.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 详情栏默认关闭 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-web-details-default-closed.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml new file mode 100644 index 0000000000..0cc1847c1a --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.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/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md +2026-07-31-hero-visible-while-blank-session-opens.md: 1b1f35d82731675978585d718e4ef837f0c78aa5 +2026-07-31-hero-visible-while-blank-session-opens.zh.md: 91c1a372ebc6341632820450d9e54d58c9d36916 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md rename to .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md index 6afa5d0ee2..1b1f35d827 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md @@ -1,6 +1,7 @@ # Agent Note: Hero stays visible while a blank session opens Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-hero-visible-while-blank-session-opens.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md rename to .agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md index f21e549b58..91c1a372eb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md @@ -1,6 +1,7 @@ # Agent Note: 空白会话打开期间保持 hero 可见 Status: implemented +Archived: 2026-08-07 [English](2026-07-31-hero-visible-while-blank-session-opens.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml new file mode 100644 index 0000000000..cb05519fde --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.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/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +2026-08-04-conversation-column-one-axis-scroll.md: e8f80c23a2ac2230079802fb6c85fec6c8b8e807 +2026-08-04-conversation-column-one-axis-scroll.zh.md: a7378b2d5ec026d6a054a080347b155cc476a57a diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md rename to .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md index 9a487c506a..e8f80c23a2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -1,6 +1,7 @@ # Agent Note: The conversation column scrolls on one axis Status: implemented +Archived: 2026-08-07 English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md similarity index 97% rename from .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md rename to .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md index 23441a7c86..a7378b2d5e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -1,6 +1,7 @@ -# Agent Note:会话列只在一个轴上滚动 +# Agent Note: 会话列只在一个轴上滚动 -状态:已实现 +Status: implemented +Archived: 2026-08-07 [English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.i18n.yaml new file mode 100644 index 0000000000..535a0a8ff3 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.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/archived/feature/2026-07-22-docked-web-goal-bar.md +2026-07-22-docked-web-goal-bar.md: decf40996b51a0f2358a943bbb928f00d4db2026 +2026-07-22-docked-web-goal-bar.zh.md: 8ebb106b260a6107084c07b51b1763adb1df2da8 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md rename to .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md index ffddef6cec..decf40996b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.md @@ -1,6 +1,7 @@ # Agent Note: Docked web goal bar Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-22-docked-web-goal-bar.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md rename to .agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.zh.md index b732f71cfc..8ebb106b26 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/archived/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -1,6 +1,7 @@ # Agent Note: 停靠式 Web 目标条 Status: implemented +Archived: 2026-08-07 [English](2026-07-22-docked-web-goal-bar.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml new file mode 100644 index 0000000000..47400cc87e --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.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/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md +2026-07-29-web-message-icon-actions-and-clock.md: a95c7d33a917026c882f17d30264cf9ec743dee5 +2026-07-29-web-message-icon-actions-and-clock.zh.md: b64c14aaa7056e19ccc4d512db3d24714e11a309 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md rename to .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md index feced6aeb1..a95c7d33a9 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -1,6 +1,7 @@ # Agent Note: Web message IconActions and clocks Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md rename to .agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 5e33182421..b64c14aaa7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/archived/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 消息 IconActions 与时钟 Status: implemented +Archived: 2026-08-07 [English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.i18n.yaml new file mode 100644 index 0000000000..1e8be61bca --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.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/archived/feature/2026-07-30-dsh-dump-config.md +2026-07-30-dsh-dump-config.md: cc16f11d79b536a661d67811c6fd50705f6009e3 +2026-07-30-dsh-dump-config.zh.md: 185a2b8f37cef102b48d4dea1b3ed0a96958d220 diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md rename to .agents/notes/archived/feature/2026-07-30-dsh-dump-config.md index bc6504541c..cc16f11d79 100644 --- a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md +++ b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.md @@ -1,6 +1,7 @@ # Agent Note: dsh --dump-config prints the composed config tree Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-dsh-dump-config.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md rename to .agents/notes/archived/feature/2026-07-30-dsh-dump-config.zh.md index 5e173305a6..185a2b8f37 100644 --- a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-dsh-dump-config.zh.md @@ -1,6 +1,7 @@ # Agent Note: dsh --dump-config 打印合成后的配置树 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-dsh-dump-config.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml new file mode 100644 index 0000000000..5105501452 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.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/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md +2026-07-30-web-composer-stats-and-input-polish.md: 3c80b74c564a1779c69ef525f8d1f194e8915f6b +2026-07-30-web-composer-stats-and-input-polish.zh.md: eabb174e78f3171997b9103650c7fe326793ce1b diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md rename to .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md index 78f286cb0e..3c80b74c56 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md +++ b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.md @@ -1,6 +1,7 @@ # Agent Note: Web composer stats detail and input-zone polish Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-web-composer-stats-and-input-polish.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md rename to .agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md index eeba56d9f3..eabb174e78 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web composer stats detail and input-zone polish Status: implemented +Archived: 2026-08-07 [English](2026-07-30-web-composer-stats-and-input-polish.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml new file mode 100644 index 0000000000..09f2268a0f --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.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/archived/feature/2026-07-30-web-context-injection-disclosure.md +2026-07-30-web-context-injection-disclosure.md: e9551cacdcd5b3e45ba35eeb76db6c70e5bbe368 +2026-07-30-web-context-injection-disclosure.zh.md: a4937ba880df30b911f7c8a1eceaab7e2bb74026 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md rename to .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md index 84c3259f3f..e9551cacdc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md +++ b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md @@ -1,6 +1,7 @@ # Agent Note: Web context injection disclosure Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-web-context-injection-disclosure.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md rename to .agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.zh.md index 4d77e06e27..a4937ba880 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 上下文注入展开项 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-web-context-injection-disclosure.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.i18n.yaml new file mode 100644 index 0000000000..d0bc2ac45e --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.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/archived/feature/2026-07-31-hover-card-click-copy.md +2026-07-31-hover-card-click-copy.md: 906f64129ee9ba767859260a3288faada21ea4de +2026-07-31-hover-card-click-copy.zh.md: 0359b2edacde9e048db900d315b6a7befd361075 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md rename to .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md index c87734fe32..906f64129e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md +++ b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md @@ -1,6 +1,7 @@ # Agent Note: Hover cards copy their primary value on activation Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-hover-card-click-copy.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md rename to .agents/notes/archived/feature/2026-07-31-hover-card-click-copy.zh.md index 2d3bc893dd..0359b2edac 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md +++ b/.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.zh.md @@ -1,6 +1,7 @@ # Agent Note: 悬浮卡片激活时复制主要值 Status: implemented +Archived: 2026-08-07 [English](2026-07-31-hover-card-click-copy.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.i18n.yaml b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.i18n.yaml new file mode 100644 index 0000000000..fcd4878180 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.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/archived/feature/2026-07-31-web-cards-toolrow.md +2026-07-31-web-cards-toolrow.md: 9bdf5d4e8917178ec27ea5f5d24af753c0d10eab +2026-07-31-web-cards-toolrow.zh.md: ce473a8a34ba8b1bb022d681244508c56a1084f3 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md rename to .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md index caa18563a9..9bdf5d4e89 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md +++ b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.md @@ -1,6 +1,7 @@ # Agent Note: Card tool rows collapse through one ToolRow Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-web-cards-toolrow.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md rename to .agents/notes/archived/feature/2026-07-31-web-cards-toolrow.zh.md index 7eb53a163f..ce473a8a34 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md +++ b/.agents/notes/archived/feature/2026-07-31-web-cards-toolrow.zh.md @@ -1,6 +1,7 @@ # Agent Note: 卡片工具行通过同一个 ToolRow 折叠 Status: implemented +Archived: 2026-08-07 [English](2026-07-31-web-cards-toolrow.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index c5c0ce85a7..6fa5f06ceb 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -40,6 +40,9 @@ "architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml": "sha256:1eb43c420a21b7a3adf0aa5274d9aa597187630a29d7e535c5e266f82e803665", "architecture/2026-07-28-consolidated-tui-presentation.md": "sha256:e6fa4ea0c9d1d94942ab98de47c554f4e8aa3b639a1cce52113107b1dbb0f4b0", "architecture/2026-07-28-consolidated-tui-presentation.zh.md": "sha256:01814434482a84ebd7f672eb5c26fc468b568e773452563bf39ba52ad25d054a", + "architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml": "sha256:af071e07bce5d9bc8f3df65fed9dcd9b3779a98c5864badbd530363bda021b55", + "architecture/2026-07-28-dsh-native-typescript-source-launch.md": "sha256:1b56e3454277ace713e2a01c4da538c756c45bf633fd24d7b16443d584afac5d", + "architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md": "sha256:8c0f97472c2c89d2c19ae5cfa68c6e67f32b50960b08b60b46496f78ea6ffad1", "bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml": "sha256:1035dae11d049d32ab09fd7d4f950eceae44bf46ba498b3cfaf3c75102b9fb64", "bug-fix/2026-07-20-code-mode-result-card-completeness.md": "sha256:6ca2c9d4df98be18813ef38b7462db880900b5bcd6944fbcd1b8f2258006b93e", "bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md": "sha256:ed85fa7f935e5f525d566bc37a92014614983e649c75de9a9f244939097a7991", @@ -61,6 +64,9 @@ "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml": "sha256:9b8fd6c3fc5f6527890d74a70372de90ade6db5fa957246d4bbdc06ee06e072c", + "bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md": "sha256:0411033becc2835ce53cd268c9fa149830274c9f61016514087bea89562cfbb8", + "bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md": "sha256:41f024f8b7a4587a92026b4d36d77879086bc86716b0b28bf2131b85db35ee75", "bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml": "sha256:4b94aded16c60628d22414dce524e8a98a8af4fff298805ee7efc63cae02c90d", "bug-fix/2026-07-27-tool-card-single-row-fields-inline.md": "sha256:40adcd522a9a2eeacc6f2b0196d1f24888a4f57830b7490a3be3d78c86c4e968", "bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md": "sha256:a79d56c9b781442ee596b47707d1a8c80abcd6466094b01802189c8e55f16da7", @@ -70,15 +76,27 @@ "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml": "sha256:280b93ece72662501f65edd58a00cdafb5b5941e4ef1314d7198fab18950cb03", "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md": "sha256:112bdbde16b6023eeb5b8a79cd2a711385e7198d51bbfc0520e9612acaa95c8a", "bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md": "sha256:fc4e7f778ea63c4583cf81132c264cf6c4b9cc3e1818778061b0497ff16b8ef6", + "bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml": "sha256:27e60a7d822b201dee65eebc8335da8edd416ee19b9b89c056302bae69c830fd", + "bug-fix/2026-07-28-web-conversation-polish-sweep.md": "sha256:92647f06202f8711107918da9d9947767385a8b6307e772b1e868b0b1cb07412", + "bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md": "sha256:95ccc18f4b35396bdcabee8b8745e75008f6c07f829fd8719bbb35d77ac0fd95", "bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml": "sha256:50b7a32e11591719c249258ecc2ec0f45e58f1a04050d2e53f6e2650f58ba137", "bug-fix/2026-07-30-tui-adapter-registration-race.md": "sha256:7e17eb1dd8f92e1efb7a18477df277b13580840b473ffe8a5309fc70ec3cfa3e", "bug-fix/2026-07-30-tui-adapter-registration-race.zh.md": "sha256:efcbd3d82af6a58677efe1a0580edd715945b6a93418fde47badac9c01a29866", + "bug-fix/2026-07-30-web-details-default-closed.i18n.yaml": "sha256:2af5559d727f3e4afdd4946eaf89ac212c81db611db78dbd9bfabb1c4661db17", + "bug-fix/2026-07-30-web-details-default-closed.md": "sha256:27a280a817c8048718bb22927e7d9572cf99ffd0c044631e99e0fd6ea236876f", + "bug-fix/2026-07-30-web-details-default-closed.zh.md": "sha256:e047c7d02cf4b95b0c7f78f4b79af254091294b05cc75e98a8bb860ae2074189", + "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml": "sha256:42218a762ce0141d3cb43deb6c688d3705cdc4405e03851d486c78f3d25b70ef", + "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md": "sha256:a40992e89736131f5c487e5357848f14accd06e135dbec9ce242c968a5b11d43", + "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md": "sha256:e0cc576bc1c196affc9220ddabf15d735c347029c530c56454f0e585979101e1", "bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml": "sha256:cd39ae2646fdc6827bf29a63953b5463faa37d5b404ae8cc3c0913c47bc92d0c", "bug-fix/2026-07-31-tui-diff-context-line-accounting.md": "sha256:57066bccd22c2dc2c3546b363de73d13b55ff8683ee12b17a81ed2bcf536645b", "bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md": "sha256:a658d886c5eb203f5f30a6fac70ad18e4a24cf756746254723d8f1d144653c04", "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", + "bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml": "sha256:ec2ab13c899d2f138cdad0fcbbba3565395ca13bb2c6925ac0fee6518c7b1a2b", + "bug-fix/2026-08-04-conversation-column-one-axis-scroll.md": "sha256:7866cb16460aa47a958b81e904161aa655d54ac331b32f585d6429fffb5c700c", + "bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md": "sha256:e01af7c18cad86dac88720014eaeb1f5491eb7feac1e542c5a3d0fd2cc3afee5", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", @@ -157,6 +175,9 @@ "feature/2026-07-21-tui-verbose-status-line.i18n.yaml": "sha256:4371b9a46d713d4180aa5d0b1ecde1ff3cae948380a8f56474c895e6113d7824", "feature/2026-07-21-tui-verbose-status-line.md": "sha256:9dcba19ee725b1593e9413a1da5398c205a258aff2e384acd406bb618e86c7f0", "feature/2026-07-21-tui-verbose-status-line.zh.md": "sha256:203c2abac99cedf7afa2540c925367ba66f00b61b926d1cc86472a603ad2bb07", + "feature/2026-07-22-docked-web-goal-bar.i18n.yaml": "sha256:4fae22f5b921ae37feda632addace14bab8d1578c861228dd1df89a7b579f057", + "feature/2026-07-22-docked-web-goal-bar.md": "sha256:94a4b00afc231eddd0fdcf6a494157d12a12b3ae40e8d732c3c05c6811b1c1f8", + "feature/2026-07-22-docked-web-goal-bar.zh.md": "sha256:90dfa4f855a810bce6d57157049eacd6b0d9aa45e99fc296d9024adae4ebac7a", "feature/2026-07-23-trajectory-step-cell.i18n.yaml": "sha256:fe2e935a0affdef877902a40d9861ef5f55b30f40650469f6a52a4d45a92793f", "feature/2026-07-23-trajectory-step-cell.md": "sha256:185e3b87174cb6d2f2d2271fd2a74b1517d03e8570be602570d027bf6002d106", "feature/2026-07-23-trajectory-step-cell.zh.md": "sha256:51f46be43d2f5c4f78a05ed9aeec92d1f33ac988f45cf24d35528e9c43828ef3", @@ -205,24 +226,45 @@ "feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml": "sha256:0865835802348b730542adbe6b7db613750f3786993c6a14dbb2f47686c13c70", "feature/2026-07-29-tui-hidden-mode-assistant-fold.md": "sha256:a5fefebd802e2d9c3c79c7852c1c34c7bbef3f2ac2150d224608b9ec44e966ad", "feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md": "sha256:21bccd1e07ec8dc73b618f428461848bb90b6235afe0b842afb0afab2d5cc575", + "feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml": "sha256:59b09ed3c94f9b706b5ba8b567e264d9a6f4ff396245a8f4435ddfe5f4af3620", + "feature/2026-07-29-web-message-icon-actions-and-clock.md": "sha256:c4f56f6681f7fa5fdef5354cdd1d0d00ae7e77f4ebcfae6015579b8a4e9ad712", + "feature/2026-07-29-web-message-icon-actions-and-clock.zh.md": "sha256:6f8522382f644467ba39c7c0f15583de30a79ac17305cdba94c4195169cb0dc3", "feature/2026-07-30-compaction-progress-visibility.i18n.yaml": "sha256:4c2267054ad5d73aecc8d39d138a0cb532981b33175252e67252d07c3314b0f5", "feature/2026-07-30-compaction-progress-visibility.md": "sha256:2dfe07244cd784f27a9e5850801d40e96eae21a20ba10aaf56f9a793cdf5b505", "feature/2026-07-30-compaction-progress-visibility.zh.md": "sha256:6180b8aff0536147ab6ed6a78ecdbe1448fd12d89407746ecb1c7c05c73d4d60", + "feature/2026-07-30-dsh-dump-config.i18n.yaml": "sha256:b400c8cce902328989e5493301f66451de7a36e966d69c43b20935fc635aca0f", + "feature/2026-07-30-dsh-dump-config.md": "sha256:85b81dd517aaa6bb7510780acd961739c6da223bf2ddf6747d62f7a74e652d1f", + "feature/2026-07-30-dsh-dump-config.zh.md": "sha256:d0d55947bcb0ef53d534844c15928ae26eebe3fb6d43f98ab176ec36a8eac640", "feature/2026-07-30-tui-details-command.i18n.yaml": "sha256:033cea6df0a16fc68cbdb435babdc6e75c1199a8e70e1a71d87c800c40f5a044", "feature/2026-07-30-tui-details-command.md": "sha256:a13478d4e55ec6d358209b51b541413ec75d0e20dfc22196ace28020f03f0c2d", "feature/2026-07-30-tui-details-command.zh.md": "sha256:de9c449b98468cef34ce4f9a9d2a854a5d8905eecd61f80e27a9a0e4495e9901", "feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml": "sha256:4c3fc380b0512ad7c00baacd0ac610e1a78ae45374311d9bd43bab6b5e29e630", "feature/2026-07-30-versioned-tui-first-run-welcome.md": "sha256:296f153e6c839f3743078e4f5aab3b2befc211c934835238668c57bdeae52231", "feature/2026-07-30-versioned-tui-first-run-welcome.zh.md": "sha256:82871a9cca1fec46bb08a5b39daad28a44bb2419dea367b4ae41af3cf07bfa65", + "feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml": "sha256:eb0f02b1e15cb127618c82871fcea1b3ba4a9a5db670615072f15c71864729ea", + "feature/2026-07-30-web-composer-stats-and-input-polish.md": "sha256:68d0c0486219d8e886db07c4cde2285e515adf9756c1e4bc94e4bbd5cbcc2c93", + "feature/2026-07-30-web-composer-stats-and-input-polish.zh.md": "sha256:679565f7a2e183ac71132bd1cbd9d2418d77fe0e5472224b15bd310dd3931f4b", + "feature/2026-07-30-web-context-injection-disclosure.i18n.yaml": "sha256:83a8b78b0140afc8f18034674b594b998099c162ed9b9d939cb99e49cea2272b", + "feature/2026-07-30-web-context-injection-disclosure.md": "sha256:00f869b29861ff8f30e64ea3ca65aa9f8a06b33be205453ea3b679e5dfe05c4d", + "feature/2026-07-30-web-context-injection-disclosure.zh.md": "sha256:9bca10469e4d77b20c3785ce7ab6cbc628a03b8919de8a47c14466c1dbc94d36", "feature/2026-07-31-experimental-subcommand-gate.i18n.yaml": "sha256:d223669bebbf6ea65b4ec636e8e7ed618eff389117335946be713897151c6968", "feature/2026-07-31-experimental-subcommand-gate.md": "sha256:8fdee37340f7e72397cf2f440a2ca70639a987d07f0c2102e02e79c0fec4bfeb", "feature/2026-07-31-experimental-subcommand-gate.zh.md": "sha256:bcdec0f82319670a1d1de54a27b103b5e2d86306b884a5f9f415b89cd5a373f4", + "feature/2026-07-31-hover-card-click-copy.i18n.yaml": "sha256:2b95987c23e13a4499f5f3851770e8f97aa6f6df457a36b6aa818a8db08785c9", + "feature/2026-07-31-hover-card-click-copy.md": "sha256:f9a85c1603dcbdd36d26f730bf2a1f7bfaaa2267c7c30bee08cb9a94a7ce774e", + "feature/2026-07-31-hover-card-click-copy.zh.md": "sha256:b01e6edda5c6b031b5265ca0d868583fecce04cbc817fa7e8cc4433d10056e64", + "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", + "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", + "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", "process/2026-06-11-tsdown-over-dumble.i18n.yaml": "sha256:22791adb84a4b6c545173d4f1708eea51151d57e426d875e0e5423be9b6e0212", "process/2026-06-11-tsdown-over-dumble.md": "sha256:8d3c35dddd8869cc3361059dfe4b7b8ab6716d29dda232c97c2f37e92c841dc0", "process/2026-06-11-tsdown-over-dumble.zh.md": "sha256:cf11c651c13f5ffef5474e7795006ba3653c5eb08eae75a879be6499353455dc", + "process/2026-06-20-generated-cordis-catalog.i18n.yaml": "sha256:5250aaec698b25bdf5e3a02f67e531793fc968f9c38348f0c8bd11418571967f", + "process/2026-06-20-generated-cordis-catalog.md": "sha256:1f3190b759bf1445b35f25f2f18ac1c8b16d7f2e9263e2627bf3c94fac54d275", + "process/2026-06-20-generated-cordis-catalog.zh.md": "sha256:5d46da71bd73bba62ba15a9f11b21da422dbecc8ed68b6f530ceb302e1935ddb", "process/2026-07-03-documentation-graph-atlas.i18n.yaml": "sha256:b1e1ed4b7865d87f939dbf8c94c0ea1069fdf7af6fa68f695e6c9d6eccbeb123", "process/2026-07-03-documentation-graph-atlas.md": "sha256:b62e92bb12123bfa4c4dac806f584aabb6b60af4c5a6a4ab88f84bb9153e766d", "process/2026-07-03-documentation-graph-atlas.zh.md": "sha256:3485ede4a5e695643bcf9e744a62f8914cff788ae35717dac5eb6bf77e0d65cf", @@ -322,9 +364,21 @@ "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml": "sha256:7acf002ea8c1533f052c7bfc0c4e3da013ecf43c5872866a3ee4a8c2691c5e33", "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md": "sha256:f18a913096b7defd2192c4bac888a33f68075c3662703a0e28a6146897d17777", "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md": "sha256:ff48a37673c97059536fe5b61aff746133eac682145550badb049eb5c83b097c", + "simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml": "sha256:534abd90ddde9ccd35ab7e595de4242d8fa30a75908a638e5b3290f749553e5a", + "simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md": "sha256:ce449c72ed09238ba5dbe6068689db13bd33225694b2ad2c7696541f32dc0eec", + "simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md": "sha256:2f66b407b626f1f8c661d849b3b359c959e690715e0ac3f61f7e3d57d59e89c3", + "simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml": "sha256:6df56f5f5639847f0fac445abb5fea8a07f3cf9a0703d9022e50728c8f5055ca", + "simplification/2026-07-26-turndown-for-tool-web-html-markdown.md": "sha256:344c5cc2a1e79287eeda6996ae417dd2e02a7545987df0f2c9016b20ae094d93", + "simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md": "sha256:8c4f2ac12ccd23f7ada90694502f5da689344e6bba72080a89672aa9b4f1903a", "simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml": "sha256:821f96f3e203e03b80553c07b10a511926bb5014be95c7df6bffb30c8e226d31", "simplification/2026-07-27-copyable-transcript-no-gutter-bar.md": "sha256:4b6aa150bbc8a4da0acac4d20f5fb8c2b77fef7e9c4c4dba8fd8e84dec36d619", "simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md": "sha256:5225e627ff301be171434a5b9f18905fe1578f50eb2d4bf9998e126aba6cc3e3", + "simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml": "sha256:ad42430fef4a5db610f8c56acfcda79b40396418694f664a5b0fb093bff1f114", + "simplification/2026-07-30-sidebar-resize-without-visible-pill.md": "sha256:6f2cfc5121371ec19c7178b31777c223e16bebdc9b63b7654a61cadc4a765b63", + "simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md": "sha256:ab859eeb12c6a74da3c37d411af52fce37bed3195941570b2f23ccd9a00d55fe", + "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", "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", @@ -345,6 +399,9 @@ "testing/2026-07-08-shared-acp-snapshot-package.zh.md": "sha256:02da3f910c2060f70038a0d86a7ddae4a8890905600440e1373412f54fbdcea8", "testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml": "sha256:c1a22174274b9f34ef4368039b3547f221507b040bd51b87af73a2722ee6b4d2", "testing/2026-07-18-tui-terminal-state-snapshots.md": "sha256:9a7fdcbeafc34376cb049b9668e0f4e9e541f523116fb11c3af9d35c2963e908", - "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa" + "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa", + "testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml": "sha256:dd45cddb591b892739b75b0c180bde7f14008f4769227b863571475be295e1e0", + "testing/2026-07-26-execa-for-test-subprocess-plumbing.md": "sha256:1f45a69d0a7367ec5afbf112a77b355339b35270af8ff52696bee879cdf770d3", + "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b" } } diff --git a/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.i18n.yaml new file mode 100644 index 0000000000..3eb6be2fb1 --- /dev/null +++ b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.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/archived/process/2026-06-20-generated-cordis-catalog.md +2026-06-20-generated-cordis-catalog.md: 8d013a5b0c7e1b8df9f607215384f6c26a83b5b8 +2026-06-20-generated-cordis-catalog.zh.md: 2550bc805db7bea95106d444ecf9b0ad75ef91cc diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md similarity index 99% rename from .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md rename to .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md index 5005e50a2e..8d013a5b0c 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.md @@ -1,6 +1,7 @@ # Agent Note: Generated cordis events + services catalog Status: implemented +Archived: 2026-08-07 English | [中文](2026-06-20-generated-cordis-catalog.zh.md) diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md rename to .agents/notes/archived/process/2026-06-20-generated-cordis-catalog.zh.md index 384e00d23a..2550bc805d 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md +++ b/.agents/notes/archived/process/2026-06-20-generated-cordis-catalog.zh.md @@ -1,6 +1,7 @@ # Agent Note: 生成的 Cordis 事件与服务目录 Status: implemented +Archived: 2026-08-07 [English](2026-06-20-generated-cordis-catalog.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml new file mode 100644 index 0000000000..cfb5b17afd --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.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/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md +2026-07-26-eventsource-parser-for-deepseek-sse.md: 9e716cf9556c9c2d8cdf6cb85c6908d45a127a0f +2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 16c63ddc9646f6309654910bd80801bd20005545 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md rename to .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md index e7835bc738..9e716cf955 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md +++ b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md @@ -1,6 +1,7 @@ # Agent Note: Replace the hand-rolled SSE parser in llm-deepseek with eventsource-parser Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-26-eventsource-parser-for-deepseek-sse.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md rename to .agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md index 7c746079aa..16c63ddc96 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md +++ b/.agents/notes/archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md @@ -1,6 +1,7 @@ # Agent Note: 用 eventsource-parser 替换 llm-deepseek 中手写的 SSE 解析器 Status: implemented +Archived: 2026-08-07 [English](2026-07-26-eventsource-parser-for-deepseek-sse.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml new file mode 100644 index 0000000000..f79936b9fa --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.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/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +2026-07-26-turndown-for-tool-web-html-markdown.md: 46c4eba12c782146aa32df245c5f69567723935a +2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 077ea89c54c63e41bec2aabd15e744b7b8a764da diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md rename to .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md index 0e387021e3..46c4eba12c 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md +++ b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md @@ -1,6 +1,7 @@ # Agent Note: Replace tool-web's regex HTML-to-markdown converter with turndown Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-26-turndown-for-tool-web-html-markdown.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md rename to .agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md index 6c9b9a22db..077ea89c54 100644 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md +++ b/.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.zh.md @@ -1,6 +1,7 @@ # Agent Note: 用 turndown 替换 tool-web 的正则 HTML 转 markdown 转换器 Status: implemented +Archived: 2026-08-07 [English](2026-07-26-turndown-for-tool-web-html-markdown.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml new file mode 100644 index 0000000000..4f211e5ed8 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.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/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md +2026-07-30-sidebar-resize-without-visible-pill.md: 50bf43564675540df2db8e88530a82177f551407 +2026-07-30-sidebar-resize-without-visible-pill.zh.md: 41a33e039aaa29813d4232e5a6d36141b55ce947 diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md rename to .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md index cc41898990..50bf435646 100644 --- a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.md +++ b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.md @@ -1,6 +1,7 @@ # Agent Note: Sidebar resize without a visible pill Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-30-sidebar-resize-without-visible-pill.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md rename to .agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md index 9f1f521df2..41a33e039a 100644 --- a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md +++ b/.agents/notes/archived/simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md @@ -1,6 +1,7 @@ # Agent Note: 侧边栏缩放不显示胶囊 Status: implemented +Archived: 2026-08-07 [English](2026-07-30-sidebar-resize-without-visible-pill.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml new file mode 100644 index 0000000000..32c335c431 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 8ef2b7deb103f2e2a9147b4b50c7d39936ed2381 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 875e44a06e1cebf4f2b1731909c479b2f06fb06a diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md rename to .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index e2d821f395..8ef2b7deb1 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -1,6 +1,7 @@ # Agent Note: Web UI drops steer entry and interjection chrome Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md rename to .agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index b55d4a271e..875e44a06e 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web UI 去掉 steer 入口与插话 chrome Status: implemented +Archived: 2026-08-07 [English](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) | 中文 diff --git a/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml new file mode 100644 index 0000000000..2fb23f6ad5 --- /dev/null +++ b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.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/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +2026-07-26-execa-for-test-subprocess-plumbing.md: ca5edc50bf17a34809037462f8e9603b8ed28e74 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 186e5cd560b6500364c855438d6c3ff18206b52b diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md similarity index 99% rename from .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md rename to .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md index 958abc4aee..ca5edc50bf 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -1,6 +1,7 @@ # Agent Note: Adopt execa for hand-rolled test subprocess plumbing Status: implemented +Archived: 2026-08-07 English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md similarity index 99% rename from .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md rename to .agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md index 5ccadd93a1..186e5cd560 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ b/.agents/notes/archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -1,6 +1,7 @@ # Agent Note: 采用 execa 替换手写的测试子进程管道代码 Status: implemented +Archived: 2026-08-07 [English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index c897fc3bab..6fbbd961a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-13-twin-llm-adapters.md -2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2 -2026-06-13-twin-llm-adapters.zh.md: 391f9259172bc91bb4e5fc036e6064a207a7e308 +2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57 +2026-06-13-twin-llm-adapters.zh.md: 753d7900f23c0d3388be0488c291145fdccf5a95 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md index b922891d44..a4c87325a0 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -12,7 +12,7 @@ English | [中文](2026-06-13-twin-llm-adapters.zh.md) Ship **two** adapters against the one contract from the start, deliberately built on different internals: -- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the SSE-parser swap](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing. +- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the archived SSE-parser swap](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing. - `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary). The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single direct-fetch adapter would have hidden. diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 391f925917..753d7900f2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -12,7 +12,7 @@ Status: implemented 从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建: -- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 +- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([已归档的 SSE 解析器替换](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 - `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生适配器确立了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。这一分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml deleted file mode 100644 index b74717afb9..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.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/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: 773f831ec2b116d4908fcd5dc818df78c5deee5e -2026-07-28-dsh-native-typescript-source-launch.zh.md: 0e40a7e32bfaf1186ce816ec0bc1e608c76b47e0 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 8a6dbf705c..5a884924f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: 4d7b2c47db68f21e904a607f16c80d33c706c488 +2026-07-29-dsh-source-launch-tsx-esm.md: 21e912c7c7bbdd70142c202105d9a3035442884a +2026-07-29-dsh-source-launch-tsx-esm.zh.md: dc6150b7017777eea99778cc9813e17402354462 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index 93fbb248b4..21e912c7c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -4,11 +4,11 @@ Status: implemented English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md) -> Supersedes [native TypeScript source launch](2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on. +> Supersedes [native TypeScript source launch](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on. ## Problem -The [native source-launch decision](2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently. +The [archived native source-launch decision](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently. Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification. diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index 4d7b2c47db..dc6150b701 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -4,11 +4,11 @@ Status: implemented [English](2026-07-29-dsh-source-launch-tsx-esm.md) | 中文 -> 取代[原生 TypeScript 源码启动](2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。 +> 取代[已归档的原生 TypeScript 源码启动](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。 ## 问题 -[原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 +[已归档的原生源码启动决策](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 启动延迟同样是问题:off-thread 的 `module.register()` 钩子工作线程把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整的 tsx 默认形态(`--import tsx`)会因其 CJS 钩子放大解析开销而多花约 0.4s。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index 55efb87b8f..7665dc3290 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: 9d0685876b4d1bac339961c67ab08f620e499464 -2026-07-30-session-end-seed-log-boundary.zh.md: 8fa9625ea6c58b0b07d964ef2580b670893a3d75 +2026-07-30-session-end-seed-log-boundary.md: 26cd67ccbfdf5dfc62e44a53c877acf6d2fee34a +2026-07-30-session-end-seed-log-boundary.zh.md: 5ce12681da3ee46cc7c69aa6d432d25712db17fd diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index 9d0685876b..26cd67ccbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -36,7 +36,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si ## Alternatives considered -**A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store. +**A boundary written by the persistence coordinator's cold-load path.** Built first as a `session/resumed` boundary and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store. **A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index 8fa9625ea6..5ce12681da 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -36,7 +36,7 @@ Status: implemented ## Alternatives considered -**由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。 +**由持久化协调器的冷加载路径写入边界。** 最初将其实现为 `session/resumed` 边界,并在合并前放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。 **在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml deleted file mode 100644 index 712d3e2dd8..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.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/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md -2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d -2026-07-27-question-composer-rows-do-not-shrink.zh.md: 73e3e7614c1eab814080eb7c5e0d03322f1c7145 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml deleted file mode 100644 index 987b40abff..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.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/bug-fix/2026-07-28-web-conversation-polish-sweep.md -2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a -2026-07-28-web-conversation-polish-sweep.zh.md: 0f352f066da13a749f61e89f52dd20487f7726b1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml index ef84211f5c..f314df3e08 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-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-07-29-web-details-session-lifecycle.md -2026-07-29-web-details-session-lifecycle.md: cc1501440d50cb560291e416a0f2b0292e08e1c8 -2026-07-29-web-details-session-lifecycle.zh.md: 1102530f288359ebc5fb04a36c2b813da41e1318 +2026-07-29-web-details-session-lifecycle.md: 41b89fc059a02e56f53b27e5a5b48fb9488b93d7 +2026-07-29-web-details-session-lifecycle.zh.md: 82fb5e0c2608cccbb786a21971a74958417b4f10 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md index cc1501440d..41b89fc059 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md @@ -10,7 +10,7 @@ The details entry is Session-scoped, but its preferred grid width is root-scoped ## Decision -`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session preserves the layout store's initial preference, whose [visibility default is now closed](2026-07-30-web-details-default-closed.md); returning to the same Session restores its current width, and selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). +`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session preserves the layout store's initial preference, whose [archived visibility-default decision](../../archived/bug-fix/2026-07-30-web-details-default-closed.md) chose closed; returning to the same Session restores its current width, and selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). The layout store is transient and starts details closed. It neither reads nor writes `localStorage`, so reload restores the sidebar default and details closed and needs no Session-baseline exception. Manual close and reopen inside one unchanged Session retain their existing behavior. The lifecycle effect changes neither the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, nor concession-chain resizing. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md index 1102530f28..82fb5e0c26 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留布局 store 的初始首选值,该值的[可见性默认设置现为关闭](2026-07-30-web-details-default-closed.md);返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 +`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留布局 store 的初始首选值,其[已归档的可见性默认值决策](../../archived/bug-fix/2026-07-30-web-details-default-closed.md)选择关闭;返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 布局 store 是瞬时状态,详情栏在启动时保持关闭。它既不读取也不写入 `localStorage`,因此重新加载会恢复侧边栏默认值,并使详情栏保持关闭,无需会话基线例外。在同一个未变化的会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.i18n.yaml deleted file mode 100644 index 0f0ff3020b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-details-default-closed.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/bug-fix/2026-07-30-web-details-default-closed.md -2026-07-30-web-details-default-closed.md: 658b6fc2c18dc67d8759bec78997f32dcba27914 -2026-07-30-web-details-default-closed.zh.md: 5a1d0e4713e47ce3bc0cc68fa4c4f5f8c94c945f diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml deleted file mode 100644 index 202b86ce3b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.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/bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md -2026-07-31-hero-visible-while-blank-session-opens.md: 6afa5d0ee2b695d6805d20f54e82073db8028df7 -2026-07-31-hero-visible-while-blank-session-opens.zh.md: f21e549b5811d81094374b1363186a1d18fbadaf diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml deleted file mode 100644 index 754ca8bbd0..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.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/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d -2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml index 72d3ae50b8..0050d26e51 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-05-turn-tail-actions-require-a-completed-turn.md -2026-08-05-turn-tail-actions-require-a-completed-turn.md: 689d50bb86c830d6e428239f112568f00d74c9b8 -2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: 2cc426bbb82acb8f57d491b0f068e89771699357 +2026-08-05-turn-tail-actions-require-a-completed-turn.md: 44a890c955089096204a5b2a2833905c9ef9f7ed +2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md: 58ca9b2519101cae12121cd74e13bdaa90ce23cb diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md index 689d50bb86..44a890c955 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.md @@ -8,7 +8,7 @@ English | [中文](2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md) Assistant IconActions were derived from the finalized transcript alone: the last content-text assistant of each turn owned the row. That quantity is stable only after the turn closes. While a turn is still producing steps, the narration a model writes before a tool call *is* the last content assistant so far, so it took the row for as long as the tool ran and then lost it to the next step's text. Readers saw copy, branch, and a clock appear under an intermediate sentence, shift the flow by one 28px row, and disappear. The row was also incoherent in that state: its branch control was already disabled through `turnEnds`, and its `Ran for` label was already withheld through `turnTimings`, so only copy worked. -The [message chrome decision](../feature/2026-07-29-web-message-icon-actions-and-clock.md) always claimed mid-turn narration stays chrome-free; the derivation never carried a completion signal to make that true. +The [archived message-chrome decision](../../archived/feature/2026-07-29-web-message-icon-actions-and-clock.md) always claimed mid-turn narration stays chrome-free; the derivation never carried a completion signal to make that true. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md index 2cc426bbb8..58ca9b2519 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-turn-tail-actions-require-a-completed-turn.zh.md @@ -8,7 +8,7 @@ Status: implemented assistant IconActions 此前只从已定稿的 transcript(文本记录)推导:每个轮次中最后一条含内容文本的 assistant 拥有该行。这个量只有在轮次关闭后才稳定。轮次仍在产出步骤时,模型在工具调用前写下的叙述就是当时该轮次的最后一条内容 assistant,于是它在工具执行期间取得该行,等下一步的文本落定又把它交出去。读者会看到复制、分支和时钟出现在一句中间叙述下方,把流程推开一行 28px,然后消失。该行在这个状态下本身也是残缺的:分支控件已经通过 `turnEnds` 判定为禁用,`Ran for` 标签已经通过 `turnTimings` 判定为不显示,只有复制可用。 -[消息 chrome 决策](../feature/2026-07-29-web-message-icon-actions-and-clock.md)一直声称轮次中间的叙述不带 chrome,但推导过程从未拿到能让这句话成立的完成信号。 +[已归档的消息 chrome 决策](../../archived/feature/2026-07-29-web-message-icon-actions-and-clock.md)一直声称轮次中间的叙述不带 chrome,但推导过程从未拿到能让这句话成立的完成信号。 ## 决策 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 0406274fbe..661cd44ce6 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: 10f16a1cbabdd8cd383c59ad8e09787c02d0109a -2026-07-20-dsh-cli-personal-config.zh.md: 22435efbec8ea661c546ffd0c1aa9bb0ff2ebbb2 +2026-07-20-dsh-cli-personal-config.md: e3baa2dc5158893ddaf919b610e51a0b278b58eb +2026-07-20-dsh-cli-personal-config.zh.md: 8417e0b27393fddeff5c75804c39deafdd1d83f8 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 10f16a1cba..e3baa2dc51 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 @@ -41,7 +41,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences - `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. -- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics. +- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. - Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. 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 22435efbec..8417e0b273 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 @@ -41,7 +41,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences - 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 -- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)。 +- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 - 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml deleted file mode 100644 index 7712757384..0000000000 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.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/feature/2026-07-22-docked-web-goal-bar.md -2026-07-22-docked-web-goal-bar.md: ffddef6cec8eb632cd44bb5352de246db7413c02 -2026-07-22-docked-web-goal-bar.zh.md: b732f71cfc3d3f813641c2ad9c594134beb2e440 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml deleted file mode 100644 index 45c03a2347..0000000000 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.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/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: feced6aeb11d176d6c774242a4d1dae14f6730f8 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 5e33182421b423f45c84dbe1a979505f4c31b819 diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml deleted file mode 100644 index 0cd2549e55..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.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/feature/2026-07-30-dsh-dump-config.md -2026-07-30-dsh-dump-config.md: bc6504541c7868bad019a1bcd9f551435109e4c6 -2026-07-30-dsh-dump-config.zh.md: 5e173305a6cd03de3db4c763f26eeda6fba68ec7 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml deleted file mode 100644 index 653b7c0554..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.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/feature/2026-07-30-web-composer-stats-and-input-polish.md -2026-07-30-web-composer-stats-and-input-polish.md: 78f286cb0edf58d0212492024b8706ffd432ee70 -2026-07-30-web-composer-stats-and-input-polish.zh.md: eeba56d9f3c2ef10222e32b0809e099f60181ac8 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.i18n.yaml deleted file mode 100644 index b3e0c18cdd..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.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/feature/2026-07-30-web-context-injection-disclosure.md -2026-07-30-web-context-injection-disclosure.md: 84c3259f3f226e501a671cc55cacf7d7d96f61fb -2026-07-30-web-context-injection-disclosure.zh.md: 4d77e06e27badb02fb73ca2ea2a739b33c5804de diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml deleted file mode 100644 index efc896170b..0000000000 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.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/feature/2026-07-31-hover-card-click-copy.md -2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2 -2026-07-31-hover-card-click-copy.zh.md: 2d3bc893dd617a3e2e21431175c54bcd4b7ed598 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml deleted file mode 100644 index 991243278a..0000000000 --- a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.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/feature/2026-07-31-web-cards-toolrow.md -2026-07-31-web-cards-toolrow.md: caa18563a9e66f882873e8d7e84cc3ac20702033 -2026-07-31-web-cards-toolrow.zh.md: 7eb53a163f1fd22e09fcf498cb3e6b138834a2f3 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index f9c6976a0d..abb67be310 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 9070ea6ed34fffecd9fd2b90275bd31155100c75 -2026-08-04-web-context-source-and-steer-marks.zh.md: 9d7c7c0a34587071e281ff8b2cb77e359a1580c1 +2026-08-04-web-context-source-and-steer-marks.md: ca44702cf637c4250d141396ac22d206a16acc15 +2026-08-04-web-context-source-and-steer-marks.zh.md: 09cefd07e3b417b63f05ce430cfed7b30597452f diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 9070ea6ed3..ca44702cf6 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -14,13 +14,13 @@ The distinctions are already durable. `user/message.source` is the merge-extensi The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. -`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). +`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [archived disclosure decision](../../archived/feature/2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). **The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. `recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. -`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of [no steer entry or interjection chrome](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. +`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index 9d7c7c0a34..09cefd07e3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -14,13 +14,13 @@ Status: implemented transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 -`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 +`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[已归档的展开项决策](../../archived/feature/2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 **名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 `recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 -`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[取消 steer 入口与插话装饰](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 +`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[已归档的取消 steer 入口与插话装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml index f58f595671..4acabc8700 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-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/process/2026-06-20-core-data-structures-catalog.md -2026-06-20-core-data-structures-catalog.md: ef100f96b06c454cfd1ec092cc7fd23e712bdf7a -2026-06-20-core-data-structures-catalog.zh.md: 0545235f96341a638c43805de1b47a400d69e618 +2026-06-20-core-data-structures-catalog.md: 7ee1e0ac3df7cb37fc9797702d44f409da820a94 +2026-06-20-core-data-structures-catalog.zh.md: 7cb0ae216f5c5f429c18d097862350997a8335d3 diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md index ef100f96b0..7ee1e0ac3d 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -8,7 +8,7 @@ English | [中文](2026-06-20-core-data-structures-catalog.zh.md) A reader trying to understand the harness could find its *behavior* in [architecture.md](../../../../docs/architecture.md) (the service map, the session/turn/step lifecycle, the event taxonomy) but had no single place describing its *vocabulary* — the data structures that behavior moves around. The type shapes lived only in source, scattered across `packages/*/src/types.ts`, so understanding "what is a `Message`, a `SessionEvent`, a `StreamChunk`" meant reading the declarations directly. A prose catalog would help, but a catalog that paraphrases or paste-copies type definitions rots the instant a field changes — and an out-of-sync type doc is worse than none, because a reader trusts it. -So the work had two intertwined questions: **what belongs in such a catalog** (the scoping problem — a harness has dozens of cross-package types and dumping all of them helps no one), and **how to keep pasted type definitions from drifting** (the durability problem). This Agent Note records both decisions. Its sibling, [the generated cordis events + services catalog](2026-06-20-generated-cordis-catalog.md), is the *wiring*-axis complement: this one catalogs the data structures, that one the events and services that move them. +So the work had two intertwined questions: **what belongs in such a catalog** (the scoping problem — a harness has dozens of cross-package types and dumping all of them helps no one), and **how to keep pasted type definitions from drifting** (the durability problem). This Agent Note records both decisions. Its historical sibling, [the archived generated Cordis events + services catalog decision](../../archived/process/2026-06-20-generated-cordis-catalog.md), is the *wiring*-axis complement: this one catalogs the data structures, that one the events and services that move them. ## Decision @@ -50,7 +50,7 @@ The durability requirement was specific: the doc shows the **literal** current t The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and definitions, the schema DSL, presentation types, and the session/persistence split before adoption. -`verify-type-equiv` must scan the complete Markdown scope, not only manifest-named documents. Otherwise an unmanifested `type-equiv` block escapes the claimed one-to-one check. The gate therefore reports such blocks as orphans. This Agent Note records that fail-closed scan rule together with the spine-vs-seam and verbatim-match decisions; the generated Cordis catalog has the symmetric design record in [its Agent Note](2026-06-20-generated-cordis-catalog.md). +`verify-type-equiv` must scan the complete Markdown scope, not only manifest-named documents. Otherwise an unmanifested `type-equiv` block escapes the claimed one-to-one check. The gate therefore reports such blocks as orphans. This Agent Note records that fail-closed scan rule together with the spine-vs-seam and verbatim-match decisions; the generated Cordis catalog has the symmetric design record in [its archived Agent Note](../../archived/process/2026-06-20-generated-cordis-catalog.md). ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md index 0545235f96..7cb0ae216f 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md @@ -8,7 +8,7 @@ Status: implemented 试图理解 harness 的读者可以在 [architecture.md](../../../../docs/architecture.md) 中找到它的*行为*(服务图、会话/轮次/步骤生命周期、事件分类),却找不到一个统一描述其*词汇*的地方,也就是这些行为所传递的数据结构。类型形状只存在于源码中,散落在 `packages/*/src/types.ts` 各处,因此要理解「什么是 `Message`、`SessionEvent`、`StreamChunk`」,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。 -因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十种跨包边界的类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note 记下了这两项决策。与它配套的[生成的 Cordis 事件与服务目录](2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 +因此,这项工作有两个相互交织的问题:**这样的目录应包含什么**(范围问题——harness 有数十种跨包边界的类型,把它们全部倾倒进来对谁都没有帮助),以及**如何避免粘贴的类型定义发生漂移**(持久性问题)。本 Agent Note 记下了这两项决策。与它历史上配套的[已归档的 Cordis 事件与服务目录自动生成决策](../../archived/process/2026-06-20-generated-cordis-catalog.md)从*接线*维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。 ## 决策 @@ -50,7 +50,7 @@ Status: implemented 主干与 seam 规则在采纳前经过了 `BashExecRequest`、工具 schema 与定义、schema DSL、展示类型以及会话/持久化拆分的逐一测试。 -`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是 manifest 点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为未列入清单的块。本 Agent Note 将这条默认拒绝放行的扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成的 Cordis 目录在[其 Agent Note](2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 +`verify-type-equiv` 必须扫描完整的 Markdown 范围,而不仅是 manifest 点名的文档。否则,未列入清单的 `type-equiv` 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为未列入清单的块。本 Agent Note 将这条默认拒绝放行的扫描规则,连同主干与 seam 的分界决策及逐字匹配决策一并记录;生成的 Cordis 目录在[其已归档的 Agent Note](../../archived/process/2026-06-20-generated-cordis-catalog.md) 中有对称的设计记录。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml deleted file mode 100644 index 00064dc95b..0000000000 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.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/process/2026-06-20-generated-cordis-catalog.md -2026-06-20-generated-cordis-catalog.md: 5005e50a2e23c8286a8057dc57f365554bde5056 -2026-06-20-generated-cordis-catalog.zh.md: 384e00d23aafeec7c7bed9bf572a628f77150993 diff --git a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.i18n.yaml deleted file mode 100644 index 4c0e0c5b0d..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.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-07-26-eventsource-parser-for-deepseek-sse.md -2026-07-26-eventsource-parser-for-deepseek-sse.md: e7835bc738b3dec5aefd6011848525f6604e852e -2026-07-26-eventsource-parser-for-deepseek-sse.zh.md: 7c746079aa7012115bea05ec0191d665c8f860d2 diff --git a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.i18n.yaml deleted file mode 100644 index 17590b4a15..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.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-07-26-turndown-for-tool-web-html-markdown.md -2026-07-26-turndown-for-tool-web-html-markdown.md: 0e387021e3d3be3011cc0d64d37864b30aec4fdf -2026-07-26-turndown-for-tool-web-html-markdown.zh.md: 6c9b9a22dbb556cdf4eef210705e2a7e265447c4 diff --git a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml deleted file mode 100644 index 76604171be..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-30-sidebar-resize-without-visible-pill.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-07-30-sidebar-resize-without-visible-pill.md -2026-07-30-sidebar-resize-without-visible-pill.md: cc41898990fa23ff2937140186a8324217911d2e -2026-07-30-sidebar-resize-without-visible-pill.zh.md: 9f1f521df2848b15f5015719bfa6f0e0e9b7be0c diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml deleted file mode 100644 index 00c51f7dff..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: e2d821f3951af472ef1a13b7b6df88a3aa96a318 -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: b55d4a271e0c5f2729222f4652fb0cb43e5cc9f9 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 c0a15302a9..8ff1af7e8e 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: bbb40babf8abca726126678f4bccb40a63160568 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: a97221a73bab0b181562ddfb7ddab1211f87f168 +2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 +2026-08-03-explicit-config-dsh-entrypoint.zh.md: b5b464e3b45a6f3909bbf087f7005ad3f819424a diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md index bbb40babf8..e0d1e954d9 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md @@ -20,7 +20,7 @@ The CLI no longer ships a TUI application. Its TUI overlay, launcher, first-run `dsh web` retains the shared base plus Web overlay and personal-or-explicit user layer. `dsh -p` retains the one-shot Web/headless composition. The reusable TUI package initially remained after this entrypoint change, then [the package-wide removal decision](2026-08-04-remove-tui-package.md) deleted it and its SDK interface. -This decision supersedes the `dsh`-specific parts of the [dedicated TUI front door](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md), [personal config](../feature/2026-07-20-dsh-cli-personal-config.md), [guided skill commands](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md), [meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md), [shared config overlays](2026-07-29-shared-base-config-overlays.md), [config dump](../feature/2026-07-30-dsh-dump-config.md), [first-run welcome](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md), and [experimental subcommand gate](../../archived/feature/2026-07-31-experimental-subcommand-gate.md) notes. The later [package-wide removal decision](2026-08-04-remove-tui-package.md) supersedes their reusable-package decisions and consolidates the deleted launcher-identity record. +This decision supersedes the `dsh`-specific parts of the [dedicated TUI front door](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md), [personal config](../feature/2026-07-20-dsh-cli-personal-config.md), [guided skill commands](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md), [meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md), [shared config overlays](2026-07-29-shared-base-config-overlays.md), [config dump](../../archived/feature/2026-07-30-dsh-dump-config.md), [first-run welcome](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md), and [experimental subcommand gate](../../archived/feature/2026-07-31-experimental-subcommand-gate.md) notes. The later [package-wide removal decision](2026-08-04-remove-tui-package.md) supersedes their reusable-package decisions and consolidates the deleted launcher-identity record. ## Verification 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 a97221a73b..b5b464e3b4 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 @@ -20,7 +20,7 @@ CLI 不再交付 TUI 应用。TUI overlay、启动器、首次运行 onboarding `dsh web` 保留共享 base、Web overlay 与个人或显式用户层。`dsh -p` 保留一次性 Web/headless 组合。可复用 TUI 包(package)在本入口变更后起初保留,随后[全包移除决策](2026-08-04-remove-tui-package.md)将其及 SDK 接口删除。 -本决策取代以下记录中专用于 `dsh` 的部分:[独立 TUI 入口](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)、[个人配置](../feature/2026-07-20-dsh-cli-personal-config.md)、[引导式 skill 命令](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md)、[meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md)、[共享配置 overlay](2026-07-29-shared-base-config-overlays.md)、[配置转储](../feature/2026-07-30-dsh-dump-config.md)、[首次运行欢迎页](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md)和[实验性子命令门禁](../../archived/feature/2026-07-31-experimental-subcommand-gate.md)。后续的[全包移除决策](2026-08-04-remove-tui-package.md)取代了其中关于可复用包的决策,并整合了已删除的启动器身份记录。 +本决策取代以下记录中专用于 `dsh` 的部分:[独立 TUI 入口](../../archived/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)、[个人配置](../feature/2026-07-20-dsh-cli-personal-config.md)、[引导式 skill 命令](../../archived/feature/2026-07-28-dsh-guided-skill-session-commands.md)、[meta workspace](../../archived/feature/2026-07-28-dsh-meta-source-workspace.md)、[共享配置 overlay](2026-07-29-shared-base-config-overlays.md)、[配置转储](../../archived/feature/2026-07-30-dsh-dump-config.md)、[首次运行欢迎页](../../archived/feature/2026-07-30-versioned-tui-first-run-welcome.md)和[实验性子命令门禁](../../archived/feature/2026-07-31-experimental-subcommand-gate.md)。后续的[全包移除决策](2026-08-04-remove-tui-package.md)取代了其中关于可复用包的决策,并整合了已删除的启动器身份记录。 ## 验证 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml deleted file mode 100644 index f3676ba1ef..0000000000 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.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/testing/2026-07-26-execa-for-test-subprocess-plumbing.md -2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044 -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 5ccadd93a182ba299be80d48881fe1c470a2d537 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml deleted file mode 100644 index ec95910194..0000000000 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.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/rejected/architecture/2026-07-29-session-resumed-log-boundary.md -2026-07-29-session-resumed-log-boundary.md: 877b0c780f4c92983d2762243fac4e26d945887a -2026-07-29-session-resumed-log-boundary.zh.md: a6f6ecc0d3f3a349f1a438eec0a84f27f8f649b2 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md deleted file mode 100644 index 877b0c780f..0000000000 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: Record the resume process boundary in the session log - -Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the end-seed boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) - -English | [中文](2026-07-29-session-resumed-log-boundary.zh.md) - -## Problem - -A session's durable log gave no evidence that it had changed processes. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals rather than `SessionEventMap` members, and `agent/session-start` carries a `SessionStartSource` but is emit-only and never logged. Reading a stored log therefore gave no hint that anything had been resumed. - -That gap makes one class of question unanswerable. A plugin that owns a standalone open/close pair in the log — compaction's `compact/start` … `compact/end` is the only one today — must distinguish an unmatched opening marker left by a process that died mid-operation from one an operation is holding right now. Those two states are **byte-identical in stored history**. Without a boundary the owner has to choose between refusing forever (an unmatched marker wedges the operation permanently, and because automatic compaction failure is warn-and-continue the user-visible result is that compaction silently stops working until the context window overflows) and proceeding always (which defeats the point of holding a lock). - -The pressure to fix this is immediate: moving `compact/start` to its real time point, before summarization, widens the crash window from a few microseconds of synchronous appends to the length of a whole model call, so orphaned brackets go from rare to routine. - -## Proposal - -`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event before the boundary has a smaller seq and was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one. - -The predicate a bracket owner evaluates is purely a function of the log: an unmatched opening marker with a `session/resumed` after it is stale, and one with no `session/resumed` after it is live. - -`time` is `Date.now()` floored at the log's greatest `time`, deliberately unlike the synthetic closers, which reuse the last real event's timestamp so repair output stays a deterministic function of stored history. The wall clock is not monotonic — an NTP step, a VM restore, or a log copied from a machine that was ahead can put it behind events already stored — so the floor keeps every cross-boundary duration non-negative. The floor is durable, because the clamped boundary is stored and joins the log's maximum: one future-dated event pins every later boundary in that log to the same instant until wall time passes it. - -**The predicate distinguishes process succession, not concurrent writers.** `load()`'s liveness guard is `ctx.sessions.get(id)`, which only sees sessions live in *this* runtime, and no backend takes a cross-process per-session lock. So process B cold-loading a session A currently owns writes a boundary after A's still-open bracket. A consumer that must tolerate concurrent writers still needs a liveness signal beyond the log. - -## Why this was rejected - -Two reasons, found while reviewing where the marker belonged. - -**It covers no fork.** `sessions.fork()` and a subagent fork child construct a seeded session without touching persistence, so neither gets a boundary. A forked child inherits its parent's prefix verbatim — including an open `compact/start` the parent is still holding — which is the one case where the inherited bracket's owner is demonstrably alive. The predicate was unavailable exactly where it was most needed. - -**Minting the marker at load made a read path a durable write.** Every consequence the review surfaced traced to that: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, the durable time floor above, a load that fails against a read-only store, and a marked log after a resume the caller then cancelled. None of these are wrong given the placement; they are the placement's cost. - -The successor keeps the problem statement and the concurrent-writer scope limit unchanged, and moves the write to `Session`'s constructor — the single waist all six seeded-start paths pass through, fork included. Because the marker then rides the ordinary seed-persistence path, the whole durable-write surface above disappears. - -## Alternatives considered - -**Use `Session.firstLiveSeq` as the staleness predicate.** Dismissed here on the grounds that it is documented as deliberately not persisted, so the same stored log yields different answers in different processes and a read-only reader cannot evaluate it at all. That reasoning was sound about the field and wrong about the conclusion: the fix is to persist a projection of it rather than to compute the boundary somewhere else. This is the alternative that became the successor. - -**Declare the event in core (`dsh-session`).** Rejected here because "the constructor cannot distinguish resume from fork or replay." That is true and turned out not to matter — the distinction is not needed, since inherited history is dead history in all three cases. - -**Teach `interruptedTurnClosers` to close `compact/*`.** Rejected: `compact/*` is plugin-owned vocabulary and core must not know it. Core closes turn, step, and tool boundaries — the relations it owns. The successor keeps this rejection. - -**Lazy self-repair: the owner appends a synthetic closing marker when it finds an orphan.** A write inside a read-shaped check, and it needs an invariant exception for a numbered owner whose turn has already closed. - -**A merge-extensible repair-contributor registry in core.** The right shape once a second consumer exists; with one consumer today, `packages/AGENTS.md` says not to split a seam preemptively. - -**Write the boundary only when repair actually occurred.** Rejected: the predicate must hold for an orderly restart too, where there is nothing to repair. The successor keeps this rejection. - -## Related - -The cold-session `updatedAt` skew this proposal documented is scoped in [the last-activity-index Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). That defect predates this proposal and survives its rejection: it is caused by mtime counting every durable write, not by any one boundary. diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md deleted file mode 100644 index a6f6ecc0d3..0000000000 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: 在会话日志中记录恢复的进程边界 - -Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[种子结束边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)取代 - -[English](2026-07-29-session-resumed-log-boundary.md) | 中文 - -## Problem - -会话的持久日志此前无法证明它换过进程。`session/created`、`session/disposed` 和 `session/flush` 是 cordis 运行时信号,而不是 `SessionEventMap` 成员;`agent/session-start` 虽然携带 `SessionStartSource`,却只用于 emit,从不记录。因此,读取一份已存储日志得不到任何关于「曾经发生过恢复」的线索。 - -这一空缺让一类问题无法回答。在日志中拥有独立开始/结束事件对的插件必须区分两种未匹配的起始标记:一种由某个在操作中途死亡的进程留下,另一种正被当前某项操作持有;今天符合这一形态的只有压缩的 `compact/start` … `compact/end`。这两种状态**在已存储历史中逐字节相同**。没有边界,所有方只能在两种做法之间选择:永远拒绝(一个未匹配的标记会永久卡住该操作,而自动压缩失败采取警告并继续的策略,因此用户可见的结果是压缩静默停止工作,直到上下文窗口溢出),或者始终继续(这让持有锁失去了意义)。 - -修复它的压力是即刻的:把 `compact/start` 移到摘要生成之前这个真实的时间点,会把崩溃窗口从几微秒的同步追加扩大为一整次模型调用的时长,孤儿括号也就从罕见变为常态。 - -## Proposal - -`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之前的每个事件都有更小的 seq,并且都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。 - -括号所有方求值的谓词纯粹是日志的函数:未匹配的起始标记之后有 `session/resumed` 的就是陈旧的,之后没有的就是存活的。 - -`time` 取 `Date.now()` 并以日志的最大 `time` 为下限,刻意区别于合成 closers——后者复用最后一个真实事件的时间戳,以便修复输出始终是已存储历史的确定性函数。挂钟并非单调:一次 NTP 跳变、一次虚拟机恢复,或一份从走快的机器上拷来的日志,都可能让它落在已存储事件之后,因此这个下限让跨边界的时长都非负。该下限是持久的,因为被钳制的边界本身会被存储并加入日志的最大值:一个未来时间的事件会把该日志中之后的每个边界都钉在同一时刻,直到挂钟时间越过它。 - -**该谓词区分的是进程接替,不是并发写入方。** `load()` 的存活性守卫是 `ctx.sessions.get(id)`,它只看到*本*运行时中存活的会话,而且没有任何后端会取跨进程的按会话锁。因此,进程 B 冷加载一个 A 当前拥有的会话时,会在 A 仍然开放的括号之后写入一个边界。必须容忍并发写入方的消费方仍然需要日志之外的存活信号。 - -## 为什么被否决 - -两个原因,都是在复审标记应当落在何处时发现的。 - -**它完全覆盖不到 fork。** `sessions.fork()` 与子代理 fork 子会话在不触及持久化的情况下构造带种子会话,因此两者都拿不到边界。fork 子会话会逐字节继承父会话的前缀——包括父会话仍然持有的开放 `compact/start`——而这恰恰是继承括号的所有方明显还活着的唯一情形。谓词偏偏在最需要它的地方不可用。 - -**在加载时铸造标记,把读取路径变成了持久写入。** 复审暴露出的每一项后果都源于此:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、上文那个持久时间下限、加载在只读存储上会失败,以及调用方随后取消的恢复也已留下标记。这些在该放置方式下都不算错,它们就是该放置方式的成本。 - -取代方案保留问题陈述与并发写入方的适用范围限制不变,并把写入移到 `Session` 的构造函数——全部六条带种子启动路径(含 fork)必经的唯一收窄处。由于标记随后走普通的种子持久化路径,上述整个持久写入面就消失了。 - -## Alternatives considered - -**用 `Session.firstLiveSeq` 作为陈旧性谓词。** 此处以「文档明确它有意不做持久化,因此同一份已存储日志在不同进程中会给出不同答案,而只读读取方根本无法对它求值」为理由否决。这个推理对字段本身是成立的,但结论错了:正确的修法是持久化它的一个投影,而不是把边界挪到别处去算。这条替代方案正是后来的取代方案。 - -**在核心(`dsh-session`)中声明该事件。** 此处以「构造函数无法把恢复与 fork 或回放区分开」为理由否决。这句话是对的,但事实证明它无关紧要——并不需要这种区分,因为在这三种情形下继承历史都是死历史。 - -**教 `interruptedTurnClosers` 关闭 `compact/*`。** 否决:`compact/*` 是插件所属词汇,核心不得知道它。核心只关闭轮次、步骤和工具边界,也就是它自己拥有的关系。取代方案保留这条否决。 - -**惰性自修复:所有方发现孤儿时自行追加一条合成的关闭标记。** 这是在一次形似读取的检查中执行写入,而且需要为一个带轮次编号、其轮次却已经关闭的所有方开一个不变式例外。 - -**在核心中建一个可合并扩展的修复贡献方注册表。** 一旦出现第二个消费方,这就是正确的形状;今天只有一个消费方,而 `packages/AGENTS.md` 要求不要预先拆分 seam。 - -**仅在确实发生了修复时才写入边界。** 否决:该谓词对有序重启同样必须成立,而那时没有任何东西需要修复。取代方案保留这条否决。 - -## 相关 - -本提案记录过的冷会话 `updatedAt` 偏斜,范围界定在[最后活动索引 Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)。该缺陷早于本提案存在,并且在本提案被否决后依然存在:它的成因是 mtime 会计入每一次持久写入,而不是某一个边界。 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml deleted file mode 100644 index ea631fa09c..0000000000 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.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/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md -2026-07-04-prune-unimplemented-subagent-vocabulary.md: 276e832af695acbcf70103def8b51fb8c6e1033f -2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md: 81b79c77a055f97785c6a96b7b17802878ded623 diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md deleted file mode 100644 index 276e832af6..0000000000 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Prune the unimplemented subagent seam vocabulary - -Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below records the decision-time state. - -English | [中文](2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md) - -## Problem - -The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) shipped a two-tier capability design: start-time capability flags checked by the service, and optional runtime methods on `SubagentRun`. Three start-time features and both optional runtime methods have zero implementations and zero callers: - -- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests. -- **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*. - -The only reason `dsh-subagent` depended on `dsh-tools` at the decision point was `outputSchema`'s schema type (now `ObjectJsonSchema`). Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer. - -## Proposal - -Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the scripted fixture's structured branch and capability knobs, and the tests that exist to pin the removed surface. Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../../docs/core-data-structures/subagent.md) pastes and the type-equiv manifest, plus the affected provider READMEs. The implementing PR amends the seam Agent Note's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). - -**Keep** `depthLimit`/`maxDepth` and capability checks. The in-process backend enforces the limit, although the shipping tool does not yet set it. Recursion is a known seam risk, so the appropriate follow-up is to supply a tool default rather than delete working enforcement. - -Adjacent surface examined and deliberately left alone: `SubagentService.getProvider()`/`list()` have test-harness consumers only, but the [prune-dead-seam-methods implementation note](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md) records precisely this shape being removed from the bash executor and reverted — a test harness IS a consumer for a one-line accessor over an already-tracked map. `SubagentRunEndInfo.lastAssistantMessage` is a recorded keep (the [subagent-observe-enrich Agent Note](../../archived/feature/2026-06-30-subagent-observe-enrich.md)'s review dropped `agentType` and kept it deliberately, as the only final-message channel for out-of-process children); its currently-unwired bridge forwarding is a gap to close or a consumer to document, not surface for this Agent Note to cut. - -This is the seam-vocabulary echo of [prune dead methods from the persistence seam](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md): members every implementation must declare for nobody — weaker even, since here zero implementations exist. - -## Alternatives considered - -### Why not keep it? - -The two-kinds-of-capability design is the seam Agent Note's headline, and re-adding `outputSchema` later touches several files. But the design survives with `depthLimit` as its live example and the Agent Notes as its record, and the seam Agent Note itself concedes the shipped `toolFilter` shape is wrong (real enforcement needs a `tools/pre-execute` deny in the child's context, not schema filtering) — that deny primitive exists on the interception seams, so re-adding against a real implementing provider will pin a better contract than the current speculative one. - -## Acceptance criteria - -- The removed spellings appear only in this Agent Note and the amended seam Agent Notes; `SubagentCapabilities` is `{ depthLimit: boolean }`; the `dsh-tools` dependency edge is gone (`hygiene` green). -- Depth-enforcement tests are unchanged and green. - -## Risks - -The subagent lifecycle events carry `lastAssistantMessage` on the end payload — that enrichment lives in the service module, not the seam vocabulary this Agent Note shrinks, and the observe-enrich Agent Note records dropping an `agentType` sibling for lacking a consumer: the judgment this Agent Note extends. The CC hooks bridge, the first outside consumer of those lifecycle events, reads only the event payloads and touches none of the surface removed here; the observe-enrich Agent Note's deferred control-flow redesign names implementing `resume` as its own future work — exactly the re-add trigger this Agent Note's pattern anticipates. diff --git a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md b/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md deleted file mode 100644 index 81b79c77a0..0000000000 --- a/.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 裁剪未实现的 subagent seam 词汇 - -Status: rejected — 延后的能力词汇(`outputSchema`/`structured`、`toolFilter`、`sendMessage`/`resume`)是有意保留的接口面:该 seam 按设计先于实现声明完整的预期契约,使提供方与消费方沿稳定形状演进,而非针对每项能力重新协商。下方的消费方证据分析记录了决策时的状态。 - -[English](2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 中文 - -## 问题 - -[subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 交付了一套两层能力设计:启动时由服务检查的能力 flag,以及 `SubagentRun` 上的可选运行时方法。三个启动时功能和两个可选运行时方法的实现数与调用数均为零: - -- **`outputSchema`/`structured` 与 `toolFilter`**(`SubagentCapabilities`、`SubagentStartRequest`、`SubagentResult`,位于 `packages/subagent/subagent/src/types.ts`):在作出决策时,每个真实提供方都声明 `outputSchema: false, toolFilter: false`(`packages/subagent/subagent-spawn/src/index.ts`、`packages/subagent/subagent-fork/src/index.ts`、`packages/subagent/subagent-acp/src/index.ts`);唯一的生产环境 `ctx.subagents.start` 调用方(`packages/subagent/tool-subagent/src/index.ts`)构造 `{ prompt, parent, signal?, agentOptions? }`,结构上无法设置这两个字段;`structured` 仅出现在脚本化测试 fixture(测试前置数据)中。服务的能力检查包含两行 assert,其唯一执行者是拒绝测试。 -- **`SubagentRun.sendMessage` / `SubagentRun.resume`**(同一文件):没有任何提供方实现——包括 mock 也没有;spawn spec 断言的正是它们的*缺失*。 - -在作出决策时,`dsh-subagent` 依赖 `dsh-tools` 的唯一原因是 `outputSchema` 的 schema 类型(现为 `ObjectJsonSchema`)。三项后续 subagent 工作(按会话快照回放、fork seed 边界、ACP(Agent Client Protocol)后端)都围绕这块接口面落地,却连一个消费方都没有产生。 - -## 提案 - -从 seam 中移除 `outputSchema`/`structured`、`toolFilter`、`sendMessage` 与 `resume`;将 `SubagentCapabilities` 缩减为 `{ depthLimit }`;删除两行能力 assert、三个提供方上的 all-false flag、脚本化 fixture 的 structured 分支和能力旋钮,以及为固定被移除接口面而存在的测试。`dsh-tools` 的对等依赖(peer dependency)和开发依赖应从 `packages/subagent/subagent/package.json` 中删除。更新 [subagent.md](../../../../docs/core-data-structures/subagent.md) 中的粘贴内容与 type-equiv manifest(元数据清单),以及受影响的提供方 README。实现 PR(Pull Request)按照 [implemented/AGENTS.md](../../implemented/AGENTS.md) 修订 seam Agent Note 的能力目录。 - -**保留** `depthLimit`/`maxDepth` 与能力检查。进程内后端已强制执行该限制,尽管当前发布的工具尚未设置它。递归是已知的 seam 风险,因此恰当的后续工作是提供一个工具默认值,而非删除正在工作的强制逻辑。 - -审视过但有意不动的相邻接口面:`SubagentService.getProvider()`/`list()` 仅有测试 harness 消费方,但 [prune-dead-seam-methods 实现说明](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md)恰好记录了这种形态从 bash 执行器中被移除后又被回退的经过——对于一个基于已跟踪 map 的单行访问器而言,测试 harness 就是消费方。`SubagentRunEndInfo.lastAssistantMessage` 是一个已记录的保留项([subagent 观测/丰富化 Agent Note](../../archived/feature/2026-06-30-subagent-observe-enrich.md)的评审删除了 `agentType` 但有意保留了它,因为它是进程外子 agent(智能体)唯一的最终消息通道);它当前未接通的桥接转发是一个待补的缺口或待记录的消费方,不是本 Agent Note 要裁剪的接口面。 - -这是[从持久化 seam 裁剪死方法](../../archived/simplification/2026-06-20-prune-dead-seam-methods.md)在 seam 词汇层面的回响:每个实现都必须声明、却无人使用的成员,甚至更弱,因为这里连一个实现都没有。 - -## 曾考虑的替代方案 - -### 为什么不保留? - -两类能力的设计是 seam Agent Note 的核心亮点,日后重新添加 `outputSchema` 会涉及多个文件。但该设计以 `depthLimit` 作为活跃示例、以 Agent Note 作为记录仍然成立;而且 seam Agent Note 本身承认已交付的 `toolFilter` 形态是错误的(真正的强制需要在子 agent 上下文中实施 `tools/pre-execute` deny,而非 schema 过滤)——该 deny 原语已存在于拦截 seam 上,因此在由真实提供方实现并重新添加时,将确定一份比当前推测性契约更好的契约。 - -## 验收标准 - -- 被移除的拼写仅出现在本 Agent Note 和修订后的 seam Agent Note 中;`SubagentCapabilities` 为 `{ depthLimit: boolean }`;`dsh-tools` 依赖边已消除(`hygiene` 绿色)。 -- 深度强制测试不变且绿色。 - -## 风险 - -subagent 生命周期事件在结束载荷上携带 `lastAssistantMessage`——该增强位于服务模块中,不在本 Agent Note 缩减的 seam 词汇范围内;observe-enrich Agent Note 记录了因缺少消费方而删除 `agentType` 兄弟字段的判断,本 Agent Note 延续了这一判断。CC 钩子桥接是这些生命周期事件的第一个外部消费方,它只读取事件载荷,不涉及本文移除的任何接口面;observe-enrich Agent Note 推迟的控制流重设计将实现 `resume` 列为自身的未来工作——恰好是本 Agent Note 模式所预期的重新添加触发点。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 7f8463064f..0f5dedddd2 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 421ce93a20c567cac4d6a96f806949348dff3e6b -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 8ae9eb83269a910160aef3a563dce6d83dbc7ad8 +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 538f6a41b4d2db72f867e98810e8e9382cbdac98 +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: b3f7a1d9717397ee9ed50890bc024fba7d79fa9a diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index 421ce93a20..538f6a41b4 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -18,7 +18,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`vscode-languageserver-types` for lsp-local's wire-type subset**: ~80 type lines and ~45 guard lines, but upstream guards differ in both directions (accept `uri: undefined` the repo must reject; require `targetRange` the repo tolerates absent), and the initialize-result shapes live in `vscode-languageserver-protocol`, dragging `vscode-jsonrpc` in as a runtime dep — ~1 MB for 80 spec-exact lines. - **`json-rpc-2.0` for `dsh-jsonrpc`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [GUI RPC note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). - **`jsonrpcclient` for the Python SDK client**: v4 builds/parses messages only — ~20 lines — while the 500 lines that matter (subprocess lifecycle, threaded reader, id correlation, bidirectional server-role responses) stay; the library is in low-maintenance mode. -- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [llm-deepseek proposal](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) +- **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [archived llm-deepseek dependency decision](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) **Retry, timers, async:** @@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line. - **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing). - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does. -- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) +- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [archived execa test-infrastructure decision](../../archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill. - **node-pty everywhere for the TUI test driver**: the archived [Windows-TUI note](../../archived/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it was already the Windows leg. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 8ae9eb8326..b3f7a1d971 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -18,7 +18,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `vscode-languageserver-types` 承担 lsp-local 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。 - **以 `json-rpc-2.0` 替换 `dsh-jsonrpc`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 - **以 `jsonrpcclient` 承担 Python SDK 客户端**:v4 只做消息的构造/解析——约 20 行——而真正要紧的 500 行(子进程生命周期、线程化读取器、id 关联、双向的服务端角色应答)全都保留;该库处于低维护模式。 -- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比 [llm-deepseek 提案](../../implemented/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) +- **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比[已归档的 llm-deepseek 依赖决策](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) **重试、定时器与异步:** @@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。 - **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。 -- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) +- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见[已归档的 execa 测试基础设施决策](../../archived/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。 - **在 TUI 测试驱动器上到处使用 node-pty**:已归档的 [Windows TUI 决策](../../archived/feature/2026-07-20-windows-tui-support.md)明确否决了在每个宿主上都使用 node-pty;它当时已经是 Windows 那一条腿。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 7794833aa0..32d6c6dcce 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: 2d956f31a737d345393232aec9ce55b429e5b4d8 -README.zh.md: 087babe2ff878c69c668ad8fdf22b345f38ac204 +README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013 +README.zh.md: b0503ed2677f2ef30a51716b1735be1fa9eabe82 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2d956f31a7..0cf50146cc 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 087babe2ff..b0503ed267 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史披露决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 47cebf796f..3829879b9d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4 -README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687 +README.md: c54759f98a944565959ef21ce538eb9b12fccdf1 +README.zh.md: 32275c19bca9d6e8aa510e982d535a72eb1a06a7 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7571cb4842..c54759f98a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Hover cards -`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md). +`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Historical rationale: [the archived hover-card copy note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md). ## Markdown rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index fa0c3f2402..32275c19bc 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,7 @@ ## 悬浮卡片 -`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。 +`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。历史依据见[已归档的悬浮卡片复制 Agent Note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md)。 ## Markdown 渲染 diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index 8f2ccbd057..eb57a7b20a 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/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/tool-web/README.md -README.md: 12f5c806db66b2109888c1ec642d117f3432d0df -README.zh.md: 27b4bc54a03af6347783a9666bd926bdc74fd0c8 +README.md: 791ea87c655444e639ef85ccce737066ead8b749 +README.zh.md: ffcf2d9813dcb94d8106b2c7d5f8ee9fc25e1aaa diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 12f5c806db..791ea87c65 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -133,6 +133,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **HTML→markdown conversion degrades on inputs GFM cannot safely represent** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts at most `fetchMaxOutputChars` source characters through a real DOM. A conservative 512-level lexical guard passes deeply or ambiguously nested bodies through as raw HTML, conversion exceptions do the same, and table `colspan` is ignored because GFM has no spanning-cell representation; these bounds avoid blocking the event loop or expanding output from an untrusted numeric attribute ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). +- **HTML→markdown conversion degrades on inputs GFM cannot safely represent** — [turndown](https://github.com/mixmark-io/turndown) (with GFM tables/strikethrough) converts at most `fetchMaxOutputChars` source characters through a real DOM. A conservative 512-level lexical guard passes deeply or ambiguously nested bodies through as raw HTML, conversion exceptions do the same, and table `colspan` is ignored because GFM has no spanning-cell representation; these bounds avoid blocking the event loop or expanding output from an untrusted numeric attribute ([archived dependency decision](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md)). - **The model-facing surface is minimal by design, with promotions deferred** — `max_results` stays a config bound (not a model argument), and `web_fetch` takes only `url` (no `format`/`prompt`/LLM-summarization mode); both are named later steps in [the seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md). - **No web-specific permission policy** — both tools execute without requesting `ctx.approval`; a deployment that needs confirmation must add a `tools/pre-execute` policy, and the package does not define persistent URL/domain grants. diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index 27b4bc54a0..ffcf2d9813 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -133,6 +133,6 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex ## 已知限制与暂缓事项 -- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 +- **HTML→markdown 转换会在 GFM 无法安全表示的输入上降级**:[turndown](https://github.com/mixmark-io/turndown)(带 GFM 表格/删除线)通过真实 DOM 转换至多 `fetchMaxOutputChars` 个源字符。保守的 512 层词法守卫会将深层或嵌套有歧义的主体作为原始 HTML 直接透传,转换异常也会如此处理;表格的 `colspan` 会被忽略,因为 GFM 无法表示跨列单元格。这些限制可避免阻塞事件循环,也避免不受信任的数值属性使输出膨胀([已归档的依赖决策](../../../.agents/notes/archived/simplification/2026-07-26-turndown-for-tool-web-html-markdown.md))。 - **面向模型的接口有意保持精简,后续扩展暂缓**:`max_results` 保持为配置上限(不是模型参数),`web_fetch` 只接受 `url`(没有 `format`/`prompt`/LLM(大语言模型)摘要模式);两项都列为 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) 中的后续步骤。 - **没有 web 专用权限策略**:两个工具都不会请求 `ctx.approval` 就直接执行;需要确认的部署必须添加 `tools/pre-execute` 策略,该包不定义持久化的 URL/域名授权。 From 1c23f196fefb6d68075aab73599ca988308f1ae1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:05:55 +0800 Subject: [PATCH 80/88] fix(ui): use official hero title casing --- apps/web/tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/hmr-live.e2e.ts | 4 ++-- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- packages/client/ui-conversation/src/client/locales.ts | 2 +- packages/client/ui-conversation/tests/skeleton.spec.tsx | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 3bd781a5ee..c88a823fb7 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click() - await page.getByText('Into the unknown', { exact: false }).waitFor({ timeout: 15_000 }) + await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 1e8e81909f..cafd0fb474 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') const originalSource = await readFile(sourcePath) const originalBundle = await readFile(bundlePath) - const oldText = 'Into the unknown' - const sourceNeedle = "'hero.headline': 'Into the unknown'" + const oldText = 'Into the Unknown' + const sourceNeedle = "'hero.headline': 'Into the Unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`) if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 90587e9e4c..1aa6b3dea9 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } // The blank frame renders the hero, not the resident composer: the // headline plus the guidance placeholder are the empty state's anchors. - await expect.poll(() => page.getByText('Into the unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) if (MODE !== 'record') { diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index ad060c5d59..dfa23ca508 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Into the unknown Preview +- text: Into the Unknown Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index ce2ce36af0..3bf7e93148 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Into the unknown Preview +- text: Into the Unknown Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index c93ed04a40..21ad64e4ed 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => { // seat with `visibility:hidden`, which Playwright reports as not visible). await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 }) expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero') - expect(await page.getByText('Into the unknown').isVisible()).toBe(true) + expect(await page.getByText('Into the Unknown').isVisible()).toBe(true) expect(await page.locator('textarea').first().isVisible()).toBe(true) releaseHistory() diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index eec25939b3..df107d2cd2 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -184,7 +184,7 @@ export const en = { 'access.confirm.acknowledge': 'I understand the risks and want to continue', 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', - 'hero.headline': 'Into the unknown', + 'hero.headline': 'Into the Unknown', 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 1cf97bb61a..f2da8be7cb 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -242,7 +242,7 @@ function mount( describe('Hero chrome', () => { it('renders the English preview badge through the hero locale seat', () => { const view = render() - expect(view.getByText('Into the unknown')).toBeTruthy() + expect(view.getByText('Into the Unknown')).toBeTruthy() expect(view.getByText('Preview')).toBeTruthy() }) }) 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 81/88] 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 82/88] 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 83/88] 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 84/88] 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 85/88] 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 86/88] 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 8315bfdc1f16b28f4708ff7458432d5208e53f3b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:45:28 +0800 Subject: [PATCH 87/88] fix(notes): address archive review findings --- ...2026-08-04-conversation-column-one-axis-scroll.i18n.yaml | 6 ------ .agents/notes/archived/manifest.json | 3 --- ...2026-08-04-conversation-column-one-axis-scroll.i18n.yaml | 6 ++++++ .../2026-08-04-conversation-column-one-axis-scroll.md | 1 - .../2026-08-04-conversation-column-one-axis-scroll.zh.md | 5 ++--- .../feature/2026-07-20-dsh-cli-personal-config.i18n.yaml | 4 ++-- .../feature/2026-07-20-dsh-cli-personal-config.md | 2 +- .../feature/2026-07-20-dsh-cli-personal-config.zh.md | 2 +- packages/client/ui-conversation/README.i18n.yaml | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- 10 files changed, 14 insertions(+), 19 deletions(-) delete mode 100644 .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml rename .agents/notes/{archived => implemented}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md (99%) rename .agents/notes/{archived => implemented}/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md (97%) diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml deleted file mode 100644 index cb05519fde..0000000000 --- a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.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/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: e8f80c23a2ac2230079802fb6c85fec6c8b8e807 -2026-08-04-conversation-column-one-axis-scroll.zh.md: a7378b2d5ec026d6a054a080347b155cc476a57a diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 6fa5f06ceb..c46bb59b44 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -94,9 +94,6 @@ "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", - "bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml": "sha256:ec2ab13c899d2f138cdad0fcbbba3565395ca13bb2c6925ac0fee6518c7b1a2b", - "bug-fix/2026-08-04-conversation-column-one-axis-scroll.md": "sha256:7866cb16460aa47a958b81e904161aa655d54ac331b32f585d6429fffb5c700c", - "bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md": "sha256:e01af7c18cad86dac88720014eaeb1f5491eb7feac1e542c5a3d0fd2cc3afee5", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml new file mode 100644 index 0000000000..754ca8bbd0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.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-04-conversation-column-one-axis-scroll.md +2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d +2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md similarity index 99% rename from .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md rename to .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md index e8f80c23a2..9a487c506a 100644 --- a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -1,7 +1,6 @@ # Agent Note: The conversation column scrolls on one axis Status: implemented -Archived: 2026-08-07 English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md) diff --git a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md similarity index 97% rename from .agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md rename to .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md index a7378b2d5e..23441a7c86 100644 --- a/.agents/notes/archived/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -1,7 +1,6 @@ -# Agent Note: 会话列只在一个轴上滚动 +# Agent Note:会话列只在一个轴上滚动 -Status: implemented -Archived: 2026-08-07 +状态:已实现 [English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文 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 661cd44ce6..ee99911ee8 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: e3baa2dc5158893ddaf919b610e51a0b278b58eb -2026-07-20-dsh-cli-personal-config.zh.md: 8417e0b27393fddeff5c75804c39deafdd1d83f8 +2026-07-20-dsh-cli-personal-config.md: 2a8ae4b235823b4493d2f082d37b85806f45b662 +2026-07-20-dsh-cli-personal-config.zh.md: d8ff6c4fcc5da8f1db6f030e990118e30ae6fe41 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 e3baa2dc51..2a8ae4b235 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 @@ -41,7 +41,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences - `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. -- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md) (which prints the composed tree those patches produce) are the diagnostics. +- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../../../../apps/cli/README.md#profiles) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. - Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. 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 8417e0b273..d8ff6c4fcc 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 @@ -41,7 +41,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences - 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 -- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../simplification/2026-08-03-explicit-config-dsh-entrypoint.md)(打印这些补丁合成出的配置树)。 +- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.md#profiles)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 - 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 32d6c6dcce..b6bf25d410 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -3,4 +3,4 @@ # 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: b0503ed2677f2ef30a51716b1735be1fa9eabe82 +README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17 diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b0503ed267..8bfb96bb93 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史披露决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 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 88/88] 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)))