diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index b33a36665d..56eeb36b4d 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/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 .agents/notes/README.md -README.md: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0 +README.md: d3a8943a78238d974d54028e38b773e932429b0e README.zh.md: 4b3a1ee57ea61a8e8ba4d01cf7c719bbf8440e30 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 3cfbb51547..d3a8943a78 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the front door and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). +One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the entry point and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 02d531f642..0c81565bdd 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 77f8e379e2b07ecf4e67fa7197752543cfedd6dd -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: ce4a04a66e345834bc2b16b743be89dc7a0b9424 +2026-07-15-llm-model-catalog-and-acp-selection.md: bfd17c73b01319c10d5dc03333b3c726db5d6f33 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aeddada5591bb2da2c0861acc368516eff148172 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index 77f8e379e2..bfd17c73b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -26,17 +26,17 @@ Catalog membership is advisory. It drives selectors and diagnostics but never ch `dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. -### Per-session selection in the front door +### Per-session selection in the front end -A selection is owned by the front door that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. +A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface. ### Prompt/request consistency and durability -`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. -The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. +The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index ce4a04a66e..aeddada559 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -26,17 +26,17 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 `dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 -### 前门内的会话级选择 +### 前端内的会话级选择 -选择由提供它的前门拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 +选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 ### 提示词/请求一致性与持久化 -`installModelSelection`(位于 `dsh-agent`)为前门拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 -请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 +请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 87c65807b8..d47c50cdb7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 7997a682c8745f7b3d0a9721acfa9355603f9b2e -2026-07-19-gui-layering-and-rpc-protocol.zh.md: cb36b5cb725128e1c5067e5e69e52aa851668e52 +2026-07-19-gui-layering-and-rpc-protocol.md: 705b0df5feb5fedaae4d198aed71758b54586e93 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: b28b08b4b9da058e01af62e610d4e226d794151f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 7997a682c8..705b0df5fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session front door](2026-08-09-headless-direct-core-front-door.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch. +The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(entry-point plugin)` directly, and wear no fetch. ## Message protocol @@ -242,7 +242,7 @@ Every client shape consumes one contract: adding a unary method is a five-step m |---|---| | Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | | A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | -| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local front door with no client boundary and uses the public Agent/Session seams rather than a client command plane | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | | webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | | Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | | Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index cb36b5cb72..b28b08b4b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的前门](2026-08-09-headless-direct-core-front-door.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(前门插件)` 挂载,不套 fetch。 +现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不套 fetch。 ## 消息协议 @@ -240,7 +240,7 @@ export type ResponseValue = |---|---| | 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | -| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地前门,使用公开的 Agent/Session seam,而不是 client 命令面 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | 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 ff517555db..e7fd7799f0 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: 496499a691dbca012e5e953cbb6eb1d0bf25b635 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c4bed7b730cf37e1d90750f5c93fbccb920ced21 +2026-07-24-web-config-tree-boot-and-transport-layering.md: 92ec665acc745e61f656bd0e57454ad266b722f9 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7cd96ad9e52c19a005e6bff356dc5159ad3a31bc 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 496499a691..92ec665acc 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,23 +16,23 @@ 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.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless front door](2026-08-09-headless-direct-core-front-door.md) and the Web gateway consume the same state. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's front-door-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is 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, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is 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, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. ## 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 is a direct core front door](2026-08-09-headless-direct-core-front-door.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. +- [Headless is a direct core entry point](2026-08-09-headless-direct-core-entry-point.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. - 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 | Rejected | One-line reason | |---|---| -| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct front doors | +| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points | | Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half | 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 c4bed7b730..7cd96ad9e5 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,23 +16,23 @@ 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 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 前门](2026-08-09-headless-direct-core-front-door.md)与 Web 网关消费同一份状态。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定前门的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- [Headless 是直接 core 前门](2026-08-09-headless-direct-core-front-door.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 +- [Headless 是直接 core 入口](2026-08-09-headless-direct-core-entry-point.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## 考虑过的替代方案 | 弃案 | 一行理由 | |---|---| -| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接前门 | +| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 | | 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 | diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 20b200713a..f3d763058b 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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 .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md -2026-08-09-headless-direct-core-front-door.md: f4604329a9276448a0021bb749b09e8c1b82e3c1 -2026-08-09-headless-direct-core-front-door.zh.md: aaa1289894bf3c69b39aa863493dffdc3437ad01 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe +2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md new file mode 100644 index 0000000000..6f1643c250 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -0,0 +1,80 @@ +# Agent Note: A session's agent is composed from a preset cordis.yml + +Status: implemented + +English | [中文](2026-08-03-per-session-agent-presets.zh.md) + +## Problem + +One `dsh` process serves many sessions, but the composition that decides what an agent *is* — its tools, persona, prompt sections, delegation backends — is fixed for the whole process by the `cordis.yml` the launcher booted. A deployment that wants a benchmark-minimal agent beside a full coding agent has to run two processes, and the shipped workaround (`apps/cli/config/minimal.cordis.yml`, a `--config` overlay that disables tool rows) changes every session at once. + +The obvious reading of "let a session pick its composition" is that the loader needs a new tier. It does not. [`dsh-tools`](../../../../packages/core/tools/README.md) and [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) already file registrations into the calling context's scope layer, and [the agent is a registration scope](2026-07-08-agent-scope-contexts.md). What was missing is a way to point a whole `cordis.yml` at one agent's scope. + +## Decision + +A **preset** is a directory holding one `agent.cordis.yml`. The agent factory's `setup(agentCtx)` mounts it as a Cordis `include` subtree plugged into that agent's scope context. Entry contexts chain to the context a subtree was plugged into, so every registration inside the preset lands in that agent's layer and unwinds with the agent. No registry gains a tier, and no session already running is touched. + +Composition splits into two planes, decided by what must be shared rather than by what feels agent-related: + +| Plane | Instances | Contents | +|---|---|---| +| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host | +| Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy | + +Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. + +The presets the deployment ships are the directories under `apps/cli/config/agent-presets/`; the roster is that listing, not a list restated here. + +Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. + +Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own. + +## Consequences + +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes. + +**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. + +**A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers. + +**A preset may not publish into the root service realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first — and the collision surfaces as an unhandled rejection that `setup` never observes, leaving a half-composed agent that looks healthy. The mount rejects it instead, and the package invariant re-checks on every service notification because a row publishing from a timer or an asynchronous continuation would escape a one-shot audit. + +**Failure rolls the agent back.** `setup` runs before publication, so a rejected mount fails `ctx.agents.create()` and leaves nothing behind. This is why `setup` is the one supported call site. + +**A test that the preset file is never rewritten has to be able to fail.** The first version asserted the file was unchanged after an ordinary mount, and could not have caught anything: the Loader only reaches its write path when it decides the config changed, and nothing in that composition ever self-disposed. The regression plants a row that disposes itself — the shape a real preset hits every time an agent is torn down — and keeps the composition in a temp root rather than under `fixtures/`, because without the override the Loader rewrites the file it read: a committed fixture would be damaged by the very run that proves the bug, and every run after it would compare against the damaged file and pass. + +**Fiber membership is object identity, not `uid`.** A `uid` is a per-registry counter, so fibers in two different roots collide on it; comparing by `uid` made one runtime's subtree answer for a service published in another. `ctx.plugin()` returns a thenable `Object.create(fiber)` wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction. + +**A preset file is an input, never a persistence target.** `EntryTree.write()` persists a tree whenever the Loader decides the config changed, and a plugin self-disposing is enough — tearing an agent down disposes its whole subtree. Inherited, that rewrites the composition it read, in practice truncating a shipped preset to `[]` the first time a session ends. The subtree overrides `write()` to do nothing. + +**A plugin that looks itself up in the global registry breaks inside a preset.** `ctx.tools.register()` files into the CALLING context's scope, so a plugin mounted in a preset registers for one agent and an unscoped `ctx.tools.get(name)` correctly finds nothing. `dsh-tool-skill` did exactly that and threw on every preset mount; it now compares against the definition it registered. Any plugin meant to be preset-mountable must hold its own registration rather than re-read it by name. + +**An entry-local `isolate` realm is invisible to the agent's own scope, not only to the host.** Only rows inside that group resolve the service. That is what makes a preset's `skills` registry belong to one agent rather than being shared — and it means a consumer left outside its provider's group silently resolves the host registry and contributes nothing. + +**Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails. + +**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. + +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. + +**A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. + +**A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. + +**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. + +**A durable header field is not durable until every backend writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and neither persistence backend carried it: the JSONL header line, the SQLite `sessions` row, and the derived query index each map the header column by column, so a resumed session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same shape — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it. + +**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. + +**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `dsh-host-apiproxy` discards the `AgentHandle` it creates, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. + +- Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam. + +## Alternatives considered + +**Add a preset tier to the scoped registries.** `ScopedLayers.merge()` combines the global layer with exactly one exact-scope layer. A middle tier would let many sessions share one mounted composition, but it changes `dsh-scope` and every scope-aware registry to save a cost measured in milliseconds, and it gives a preset's registrations a lifetime no agent owns. + +**Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions. + +**Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md new file mode 100644 index 0000000000..7afe9ade5c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -0,0 +1,81 @@ +# Agent Note:会话的 agent 由一份 preset cordis.yml 组装而成 + +Status: implemented + +[English](2026-08-03-per-session-agent-presets.md) | 中文 + +## 问题 + +一个 `dsh` 进程服务多个会话,但决定 agent(智能体)究竟是什么的那套组装——它的工具、人设、提示词段落、委派后端——由启动器所引导的 `cordis.yml` 一次性固定给整个进程。若某个部署希望一个 benchmark 精简 agent 与一个完整编码 agent 并存,就必须跑两个进程;而现有的变通方案(`apps/cli/config/minimal.cordis.yml`,一个用来禁用工具行的 `--config` 覆盖层)会一次性改变所有会话。 + +对"让会话自选组装"最直觉的理解,是 loader 需要新增一层。其实不需要。[`dsh-tools`](../../../../packages/core/tools/README.md) 与 [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册,而且 [agent 本身就是一个注册 scope](2026-07-08-agent-scope-contexts.md)。此前缺的只是一种把整份 `cordis.yml` 指向某一个 agent scope 的办法。 + +## 决策 + +**preset** 是一个目录,其中放置一份 `agent.cordis.yml`。agent 工厂的 `setup(agentCtx)` 把它作为 Cordis `include` 子树,挂载到该 agent 的 scope 上下文之下。entry 上下文沿原型链连到子树被挂载时所在的上下文,因此 preset 内部的每一次注册都落进该 agent 的分层,并随 agent 一起卸载。没有任何注册表新增分层,也没有任何已在运行的会话被触及。 + +组装划分为两个平面,依据是什么必须共享,而不是什么感觉上与 agent 有关: + +| 平面 | 实例数 | 内容 | +|---|---|---| +| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 | +| agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 | + +模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 + +部署交付哪些 preset,取决于 `apps/cli/config/agent-presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 + +挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 + +未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。 + +## 后果 + +**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id,因此恢复重建的是那份组装而不是当下的默认值,网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 + + +**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 + +**preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。 + +**preset 不得把服务发布进根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞——而这次相撞表现为 `setup` 永远观察不到的未处理 rejection,留下一个看起来健康、实则组装到一半的 agent。挂载改为直接拒绝它;本包的运行时不变量还会在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +**失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。 + +**「preset 文件从不被回写」这条断言,必须先有失败的可能。** 最初那版在一次普通挂载之后断言文件未变,其实什么也抓不到:Loader 只在认定 config 变了时才会走到写路径,而那份组装里没有任何一行会自行销毁。回归用例改为植入一个自行销毁的行——真实 preset 在每次 agent 被拆除时都会命中的形状——并把组装放在临时根目录而不是 `fixtures/` 下:没有那个覆写,Loader 会回写它读入的文件,于是提交进仓库的 fixture 会被**恰恰是证明该缺陷的那次运行**改坏,之后每一次运行都拿改坏后的文件作比较从而通过。 + +**fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。 + +**preset 文件是输入,绝不是持久化目标。** 只要 loader 认为配置变了,`EntryTree.write()` 就会回写整棵树,而一个插件自我 dispose 就足以触发——销毁 agent 会 dispose 它的整棵子树。若继承该行为,它会重写自己读入的那份组装,实际后果是第一次会话结束时把随附 preset 截断成 `[]`。子树因此把 `write()` 覆盖为空操作。 + +**按自身名字回查全局注册表的插件,在 preset 里必然失效。** `ctx.tools.register()` 归档进**调用方**上下文的 scope,因此挂在 preset 里的插件只为一个 agent 注册,而不带 scope 的 `ctx.tools.get(name)` 理所当然查不到。`dsh-tool-skill` 正是这样写的,于是每次 preset 挂载都抛错;现在它与自己注册的那个定义比对。任何希望可被 preset 挂载的插件,都必须持有自己的注册对象,而不是按名字重新读取。 + +**entry 本地 `isolate` realm 不仅对宿主不可见,对 agent 自身的 scope 同样不可见。** 只有该组内部的行能解析到该服务。这正是让 preset 的 `skills` 注册表归属单个 agent 而非共享的原因——同时也意味着:被留在提供方组之外的消费方会静默解析到宿主注册表,然后什么都不贡献。 + +**只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。 + +**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 + +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 + +**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 + +**preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 + +**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 + +**持久化的头部字段,在每个后端都写入之前都算不上持久。** `agentPreset` 带着正确的理由落在了 `SessionHeader` 上,而两个持久化后端都没有携带它:JSONL 头部行、SQLite `sessions` 行、以及派生的查询索引各自逐列映射头部,于是被恢复的会话回来时没有 preset,所有据以命名它的表层随之失声。`summarizeCold` 是同一个形状——它手工拼装冷列表行,而没有复用共享的投影。声明为持久的字段,需要一个跨越真实存储的测试,而不只是声明它的那个类型。 + +**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 + +**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`dsh-host-apiproxy` 创建后直接丢弃 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 + +- 遗留 TODO:idle agent 驱逐——会话持久化后 dispose,恢复时重新挂载。它属于持有 handle 的那个宿主,不属于本 seam。 + +## 考虑过的替代方案 + +**在 scope 注册表中新增 preset 分层。** `ScopedLayers.merge()` 把全局层与恰好一个精确 scope 层合并。新增中间层可以让多个会话共用一份已挂载的组装,但它要改动 `dsh-scope` 及每个 scope 感知的注册表,换来的只是毫秒级的开销节省,而且会让 preset 的注册获得一个没有任何 agent 拥有的生命周期。 + +**把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。 + +**把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index fcac71abf4..938e802716 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 8b5ab0c99282f6868fc3f70781e618af9a317c09 -2026-08-05-profile-plugin-bundles.zh.md: 2a685d68b3de9210488e26f8e6dd93dfc07f956c +2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b +2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 8b5ab0c992..2924b3cb44 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core front door](2026-08-09-headless-direct-core-front-door.md) owns the headless composition contract. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 2a685d68b3..b228703401 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 前门](2026-08-09-headless-direct-core-front-door.md)负责 headless 组合约定。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 [`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml new file mode 100644 index 0000000000..d8c55c9f0a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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-08-per-preset-standing-mounts.md +2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 +2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md new file mode 100644 index 0000000000..834d645f5f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -0,0 +1,32 @@ +# Agent Note: Per-preset standing mounts over a scope parent chain + +Status: implemented + +English | [中文](2026-08-08-per-preset-standing-mounts.zh.md) + +## Problem + +Per-session preset mounts made the model-facing registry surface per-agent while three independent host readers still assumed it was static: cold `session.history` found no presenters (every card silently degraded to the generic renderer — indistinguishable from "tool has no presenter"), the projections block dropped preset-registered keys (clients treat an omitted key as capability absence and CLEAR the row), and the TypeRT gateway resolved `goals` on the host root (`service-unavailable`). Patching each reader individually traded one silent degradation for another: resuming to reach presenters flipped the projections fold from detached to live and wiped the token counts instead. + +## Decision + +A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by binding its scope key to the mount's (`bindScopeParent(agentKey, standingKey)`). Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf. + +## Consequences + +Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap + +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: : true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. +- A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. + +## Load-bearing details + +- **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer. +- **A settled mount serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. +- **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it). +- **Re-linking runs only through the `ScopeParentBinding` the mount's one bind returned** — the roster holds it privately, so the blank-session recompose path is the sole re-link and no other caller can move a composed agent; it stays valid only while nothing produced under the old parent is retained, which the holder must uphold because the relation cannot see session logs. + +## Alternatives considered + +Resume-on-read (wipes detached projections), a host-plane presenter table plus a block completeness flag (fixes two readers, leaves the class), per-session template mounts (duplicates every instance to serve pure functions). Kept for the record: the gateway-facing `goals` domain stays host-plane regardless — a Remote method whose receiver comes from a generated descriptor resolves on the host, which is the `bash-env` host-plane criterion read from the consuming side. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md new file mode 100644 index 0000000000..45ce0f4e7d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Per-preset standing mounts over a scope parent chain + +Status: implemented + +[English](2026-08-08-per-preset-standing-mounts.md) | 中文 + +## Problem + +按会话挂载 preset 让面向模型的注册面变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 `session.history` 找不到 presenter(每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分)、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并**清掉**该行)、TypeRT 网关在宿主根上解析 `goals`(`service-unavailable`)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume,会把投影折叠从 detached 翻到 live,token 计数随之被抹掉。 + +## Decision + +一个 preset 是**每进程**一份组装,而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key(`bindScopeParent(agentKey, standingKey)`)加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。 + +## Consequences + +常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: + +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: : true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 +- 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 + +## Load-bearing details + +- **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 +- **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 +- **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 +- **重新认父只能经由挂载首绑返回的 `ScopeParentBinding`**——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent;其合法性仍以旧父之下产出一概不被保留为前提,由持有方保证,因为该关系看不见会话日志。 + +## Alternatives considered + +冷读时 resume(抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 `goals` 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析,这正是 `bash-env` 宿主平面判据从消费侧读出的样子。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml new file mode 100644 index 0000000000..b851627050 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-09-headless-direct-core-entry-point.md +2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a +2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md similarity index 76% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index f4604329a9..49afe2993d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -1,22 +1,22 @@ -# Agent Note: headless is a direct core front door +# Agent Note: headless is a direct core entry point Status: implemented -English | [中文](2026-08-09-headless-direct-core-front-door.zh.md) +English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md) ## Problem The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, empty stderr on success, and no listening port. A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. -The direct front door still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. +The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. ## Decision The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. -`headless-runner` is a direct core front door. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. +`headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. -`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy front doors consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. @@ -31,7 +31,7 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Alternative | Contract mismatch | |---|---| | Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | -| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot front door has no client boundary. | +| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot entry point has no client boundary. | | Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | | Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md similarity index 94% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index aaa1289894..73c1cbe5ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -2,13 +2,13 @@ Status: implemented -[English](2026-08-09-headless-direct-core-front-door.md) | 中文 +[English](2026-08-09-headless-direct-core-entry-point.md) | 中文 ## 问题 `headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,成功时 stderr 为空,并且不打开监听端口。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 -直接前门仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 +直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。 -`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接前门与 ApiProxy 前门均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 @@ -31,7 +31,7 @@ Status: implemented | 替代方案 | 约定不匹配之处 | |---|---| | 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | -| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性前门没有客户端边界。 | +| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性入口没有客户端边界。 | | 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | | 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml new file mode 100644 index 0000000000..22e5090312 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.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-09-layered-skill-registry.md +2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39 +2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75 diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md new file mode 100644 index 0000000000..3f092cfb4b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md @@ -0,0 +1,39 @@ +# Agent Note: The skill registry is host-held and layered per scope + +Status: implemented + +English | [中文](2026-08-09-layered-skill-registry.zh.md) + +## Problem + +The agent-preset stack moved the whole skill capability — registry, local provider, and the `skill` tool — into each preset's `isolate` realm, because "which skills an agent has" is an agent-plane choice. That framing conflated two different questions: which skills a *deployment* supplies, and whether an *agent* consumes them. A repository plugin's prepared wrapper declares `inject: ['skills']` and mounts its skill root as a host-plane provider; with no host registry composed in the web and headless profiles, that wrapper waited forever and the repository-plugin e2e hung, which was bypassed at the time by dropping the fixture's skill root. A per-preset realm registry also made the gateway's skill listing depend on a live agent — a cold session's `/` popup had no registry to read at all. + +The tools registry never had this problem: it is one host singleton layered per scope over `dsh-scope`, so deployment-level tools (MCP servers, plugin entries) register globally while a preset's rows register into that preset's layer. + +## Decision + +`SkillService` adopts the same shape. It holds `ScopedLayers`; `registerProvider()` and `register()` file into the layer of the calling context's scope, so host rows and repository plugins land in the global layer while a preset's `skill-local` — mounted by the standing composition, whose context carries the preset's scope key — lands in that preset's layer. Provider names are unique per layer rather than process-wide, which is what lets every preset mount its own `local` provider. + +Reads take the viewing scope through `SkillViewOptions` (the calling agent, which is its own scope key). The registry merges the global layer with the scope's chain: **the nearest layer wins a duplicate name outright, and rank decides duplicates only within one layer** — the tools registry's shadowing rule. Rank-pooling across layers was considered and rejected: ranks were designed to order sources that know about each other, and under a global pool a later-installed repository plugin could silently displace a preset's own same-named skill by registration-order tiebreak, changing a preset's behavior remotely. Nearest-wins keeps a composition's behavior decided by its author. + +Discovery caches are keyed by the resolved scope chain plus one revision counter, so a blank-session recompose — which re-parents the agent's scope key without touching the registry — is visible to the next read. + +The composition moves with it: the web-app bundle re-enables the base `skill` registry row (only `skill-local` and `tool-skill` stay preset-owned), and preset compositions drop their `isolate: skills` realm for bare rows over the host registry. The gateway's skills domain reads the host registry in the presenter scope — the live agent, else the recorded preset's standing key — so a cold session lists the catalog its composition actually serves instead of failing; the `serviceFor` branch stays for compositions that still realm-mount their own registry. + +## Consequences + +**A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only. + +**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. + +**Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee. + +**The TUI profile is unaffected.** With every row at host, there is exactly one (global) layer and the merged view equals the old single-registry view, ranks and all. + +**Shadowing across layers is silent.** Within a layer the loser is logged as before; a nearer layer replacing a farther name follows the tools registry's convention and logs nothing. The registry still exposes no API to inspect shadowed definitions. + +## Alternatives considered + +**Rank pool across all visible layers.** Faithful to the single-registry precedence, but cross-layer ties break on registration order (boot-time providers always beat standing mounts), and a preset's own skill could be displaced by a deployment change it never sees. Rejected for composition stability; see Decision. + +**Keep per-preset realm registries and deliver repository skills as directories a preset's provider scans.** Leaves the wrapper's `inject: ['skills']` contract broken (or forks the wrapper per profile), duplicates discovery configuration into every preset, and still gives cold sessions nothing to read. Rejected. diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md new file mode 100644 index 0000000000..38b17329c8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md @@ -0,0 +1,39 @@ +# Agent Note:skill 注册表由宿主持有并按 scope 分层 + +Status: implemented + +[English](2026-08-09-layered-skill-registry.md) | 中文 + +## 问题 + +agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和 `skill` 工具——搬进每个 preset 的 `isolate` realm,理由是"agent 拥有哪些 skill"属于 agent 平面的选择。这一框架混淆了两个不同的问题:*部署*供给哪些 skill,与*agent*是否消费它们。repository 插件的 prepared wrapper 声明 `inject: ['skills']` 并把它的 skill 根目录挂载为宿主平面的提供方;web 与 headless profile 不再组合宿主注册表后,该 wrapper 永远等待,repository-plugin e2e 因而挂死,当时通过删掉 fixture 的 skill 根目录绕过。按 preset 的 realm 注册表还让网关的 skill 列表依赖存活 agent——冷会话的 `/` 弹窗根本没有注册表可读。 + +工具注册表从未有过这个问题:它是一个宿主单例,基于 `dsh-scope` 按 scope 分层,因此部署级工具(MCP 服务器、插件 entry)注册进全局层,preset 的行注册进该 preset 的层。 + +## 决定 + +`SkillService` 采用同一形态。它持有 `ScopedLayers`;`registerProvider()` 与 `register()` 落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,preset 的 `skill-local`(由常驻组合挂载,其上下文携带该 preset 的 scope key)落入该 preset 的层。提供方名称在每层内唯一而非进程级唯一,这正是让每个 preset 都能挂载自己的 `local` 提供方的前提。 + +读取通过 `SkillViewOptions` 携带观察 scope(调用中的 agent,agent 本身就是自己的 scope key)。注册表将全局层与该 scope 的链合并:**最近层直接赢得重名,rank 只在单层内裁决重名**——即工具注册表的遮蔽规则。曾考虑跨层 rank 合池并予以否决:rank 的设计前提是各来源彼此知情;在全局池下,后安装的 repository 插件可能凭注册顺序平手规则静默顶掉 preset 自带的同名 skill,远程改变 preset 的行为。最近层优先让组合的行为由其作者决定。 + +发现缓存以解析后的 scope 链加一个修订计数为键,因此空会话重组——只重设 agent scope key 的父级、不触碰注册表——对下一次读取立即可见。 + +组合随之调整:web-app bundle 重新启用 base 的 `skill` 注册表行(只有 `skill-local` 与 `tool-skill` 仍归 preset),preset 组合拆掉 `isolate: skills` realm,改为直接落在宿主注册表上的平铺行。网关的 skills 域以 presenter scope 读取宿主注册表——存活 agent,否则记录在案的 preset 的 standing key——冷会话由此列出其组合真正供给的目录而不再报错;`serviceFor` 分支保留,兼容仍以 realm 自挂注册表的组合。 + +## 影响 + +**部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。 + +**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 + +**提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。 + +**TUI profile 不受影响。**所有行都在宿主时只有一个(全局)层,合并视图等于旧的单注册表视图,rank 行为不变。 + +**跨层遮蔽是静默的。**层内败者照旧记录日志;较近层顶替较远层的名称沿用工具注册表的惯例,不记录。注册表仍不提供检查被遮蔽定义的 API。 + +## 曾考虑的替代方案 + +**跨全部可见层的 rank 合池。**忠实于单注册表的优先级,但跨层平手按注册顺序裁决(启动期提供方永远赢过常驻挂载),preset 自带 skill 可能被它看不见的部署变更顶掉。因组合稳定性否决;见"决定"。 + +**保留按 preset 的 realm 注册表,把 repository skill 作为目录交给 preset 的提供方扫描。**wrapper 的 `inject: ['skills']` 契约仍然破损(或者按 profile 分叉 wrapper),发现配置在每个 preset 里重复,冷会话依旧无处可读。否决。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index c9581fcbfb..9d94121626 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-20-error-cause-chain-diagnostics.md -2026-07-20-error-cause-chain-diagnostics.md: 32716b5a68b3b73bded47633eca95995cdbfc586 -2026-07-20-error-cause-chain-diagnostics.zh.md: 9911c32b6d68c1f5569a1fceadb65e23c6586594 +2026-07-20-error-cause-chain-diagnostics.md: b80dd08d79a57738a6eef2f8638b0336ca80d4bf +2026-07-20-error-cause-chain-diagnostics.zh.md: 914e983d7ea81eef8bca8fd5aa3791f2f44d4080 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 32716b5a68..b80dd08d79 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -9,7 +9,7 @@ English | [中文](2026-07-20-error-cause-chain-diagnostics.zh.md) A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end: 1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic boundary in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line. -2. The readline front door (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. +2. The readline entry point (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 9911c32b6d..914e983d7e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -9,7 +9,7 @@ Status: implemented TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同: 1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断边界都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。 -2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 +2. readline 入口(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml new file mode 100644 index 0000000000..9ad1682b62 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.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-09-broken-preset-roster-rows.md +2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462 +2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md new file mode 100644 index 0000000000..fef6a183b1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md @@ -0,0 +1,33 @@ +# Agent Note: Broken presets are roster rows, not gaps + +Status: implemented + +English | [中文](2026-08-09-broken-preset-roster-rows.zh.md) + +## Problem + +With files as the only composition editor, hand-edit damage had two failure shapes and both were silent until the worst moment. A preset whose `agent.cordis.yml` no longer parsed listed as a perfectly ordinary row — selectable, copyable, settable as the default — and failed only when the next session tried to mount it; set as default, every new session failed to start. A directory whose composition file was deleted outright vanished from the roster while still occupying its id on disk: `copy` refused the name with "delete the existing preset first" and `remove` answered "not found" — two contradictory errors with no way out short of hand-deleting the directory. + +## Decision + +Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page. + +Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure. + +## Consequences + +- The ghost dead end is gone end to end: the directory lists broken, its delete clears it, and the freed id is immediately claimable (covered by unit, component, and e2e tests). +- A default that later breaks still fails the session start loudly — the pickers hide broken rows, but nothing rewrites a stored default; `resolveMountable`'s early refusal is the same message every unloadable shape gets, instead of loader-dependent errors. +- Health runs on every `list()`: one read+parse per preset per roster read, accepted for the same reason unmemoized discovery was — rosters are small and freshness is the contract. +- Copying broken is refused in the UI only (disabled with reason); the host keeps `copy` shape-agnostic. A broken source yields an equally broken, equally visible copy — no capability is gained, and the host-side refusal would have needed its own error vocabulary for no journey that survives the disabled button. + +## Load-bearing details + +- **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged. +- **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line. +- **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route. +- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user. + +## Alternatives considered + +Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md new file mode 100644 index 0000000000..196bcf4ef1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md @@ -0,0 +1,33 @@ +# Agent Note:损坏的 preset 是名单行,不是空缺 + +Status: implemented + +[English](2026-08-09-broken-preset-roster-rows.md) | 中文 + +## 问题 + +文件成为唯一的组装编辑器之后,手动编辑造成的损坏有两种形态,且都要拖到最糟的时刻才暴露。`agent.cordis.yml` 解析不了的 preset 在名单上是一张完全正常的行——可选择、可复制、可设为默认——直到下一个会话尝试挂载才失败;一旦被设为默认,所有新会话都无法启动。组装文件被整个删掉的目录则从名单上消失,却仍在磁盘上占着它的 id:`copy` 以「先删除既有 preset」拒绝这个名字,`remove` 却回答「找不到」——两条互相矛盾的错误,除了手动删目录别无出路。 + +## 决定 + +发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。 + +界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。 + +## 后果 + +- 幽灵死路端到端消除:目录以损坏行列出,删除即清掉,释放的 id 立刻可用(单测、组件测试与 e2e 各自覆盖)。 +- 事后才损坏的默认值仍会在会话启动处大声失败——选择器隐藏损坏行,但没有任何东西改写已存的默认;`resolveMountable` 的前置拒绝让每种不可加载形态得到同一条消息,而不是依赖加载器内部的报错。 +- 健康检查随每次 `list()` 运行:每次读名单对每个 preset 一次读取加解析,接受的理由与不做缓存的发现相同——名单很小,新鲜是契约。 +- 复制损坏 preset 只在 UI 层拒绝(按钮禁用并给出原因);宿主的 `copy` 保持形状无关。损坏来源产出同样损坏、同样可见的副本——没有能力增益,而宿主侧拒绝需要为一条被禁用按钮挡住的路径专门发明错误词汇。 + +## 关键细节 + +- **`PRESET_ID` 移到 `types.ts`**,让发现与创作共享同一份包含边界词汇;authoring 原样转发导出。 +- **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。 +- **mount.spec 的两个竞态用例特意不动**:`ensureStanding` 仍可能拿到删除前一刻解析出的 preset(私有路径测试),其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。 +- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实(preset 根目录在会话工作区之外)与诚实的验证方式(agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。 + +## 曾考虑的替代方案 + +隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 32c80847bb..e60442bb37 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 53a580aee50752b7c6daeff5caa42ba6409c8885 +2026-06-24-workspace-context.md: e7a3724847b9dc8cfad11e87b2c96a3ef442bcba 2026-06-24-workspace-context.zh.md: 39c3f52da10b7299301d10bd8b78330cbae8e19c diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 53a580aee5..e7a3724847 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. +Workspace guidance is isolated per session and shared by the demo entry points, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index b7181bfba1..af11b3f3f9 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-16-harness-level-loop.md -2026-07-16-harness-level-loop.md: e84a5738a55988d3b968eac829d9daaf10d7e304 -2026-07-16-harness-level-loop.zh.md: d7cda56d66d4456ac5c3c7d55bb238e85fcacbc5 +2026-07-16-harness-level-loop.md: e5dd94fbf76e27f0843666f29622969635ef2fc3 +2026-07-16-harness-level-loop.zh.md: 773b5ff1f4b07206d0d817c29774435ce24407b4 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index e84a5738a5..e5dd94fbf7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Code requires a direct human message in the current live root-agent turn; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. -TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC front doors do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. +TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC entry points do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. ### Fresh-agent Ralph execution diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index d7cda56d66..773b5ff1f4 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -70,7 +70,7 @@ Goal Round 驱动器为每个特定的实时 agent 至多拥有一个待定预 模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。代码要求当前实时根 agent Turn 中有一条人类直接发送的消息;语义解释仍是模型判断。自治目标 Round 可以为确切的当前 Goal Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 -TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 +TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 运行入口不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 ### 全新 agent Ralph 执行 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 146601b28d..32fa522443 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: f27d799574e61d92123c9ca4e31c5c2a9d3229b4 -2026-07-16-persistent-pty-sessions.zh.md: 0c23619faf00794c2c4e7b3f85ef961faddd99e0 +2026-07-16-persistent-pty-sessions.md: fb9cd06bade7bc357baa738f0d9dd03b7f5b7936 +2026-07-16-persistent-pty-sessions.zh.md: 55a5848c1ab1e8c2cd3b29f2d4748ea4abbe088c diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index f27d799574..fb9cd06bad 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -152,7 +152,7 @@ The package ships concise tool guidance explaining persistent state, owner isola **Include TUI sequences and BEL handling.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational. -**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. +**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current long-lived entry points already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. ## Verification diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 0c23619faf..55a5848c1a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -152,7 +152,7 @@ plugins: **包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。 -**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 +**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前长驻的运行入口已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 5b39ad725e..b45809724c 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-19-human-goal-command.md -2026-07-19-human-goal-command.md: 5fdd80f7423b80e84e58f7379130ee59a2e8a723 -2026-07-19-human-goal-command.zh.md: 89d29497abfc758ff8373d272aa8620ca1bcfcc2 +2026-07-19-human-goal-command.md: d68e4025a4d37d07211f15ddfc6069bc7c637124 +2026-07-19-human-goal-command.zh.md: dbda35c731ccdc7aff2d4213a3df8b78070319df diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index 5fdd80f742..d68e4025a4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -68,5 +68,5 @@ The producer suite uses the real command registry, goal service, agent registry, - The portable command contract has no modal editor or confirmation interaction; inline edit and explicit clear are intentional until a general cross-surface interaction primitive exists. - `/goal` does not accept a per-command round cap. Deployment config owns the default, and the authorized model tool can edit a cap after direct human instruction. - TUI renders portable plain text rather than a continuously updated goal status widget. Reconnectable command output and adapter-specific status indicators are deferred. -- The ACP automation server, headless CLI, and JSON-RPC front doors do not consume the command registry. +- The ACP automation server, headless CLI, and JSON-RPC entry points do not consume the command registry. - The command observes and mutates state but does not certify completion or blockers. Evaluator-backed certification remains deferred to a separate policy layer with an explicit authority and isolation contract. diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 89d29497ab..dbda35c731 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -68,5 +68,5 @@ TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者 - 可移植命令约定没有模态编辑器或确认交互;在出现通用跨界面交互原语之前,行内编辑与明确清除是有意选择。 - `/goal` 不接受逐命令 Round 上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 - TUI 渲染可移植纯文本,而不是持续更新的目标状态组件。可重连命令输出和适配器专用状态指示器予以延期。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC 前端不消费命令注册表。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC 运行入口不消费命令注册表。 - 该命令观察并改变状态,但不认证完成或阻塞。基于评估器的认证延期到具有明确权限与隔离约定的独立策略层。 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 26dde53462..83e3319f6d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-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/feature/2026-07-19-plugin-command-registration.md -2026-07-19-plugin-command-registration.md: c8f0f2772a41948e9eb257a16f40194518c568f9 -2026-07-19-plugin-command-registration.zh.md: a8f7a3d1e7947aeb41344a72106cee55d249010c +2026-07-19-plugin-command-registration.md: 76feba84492bd6b245246a6f1fb1e8f68d555684 +2026-07-19-plugin-command-registration.zh.md: e89cb79ba1e15dfefa527fe7bc37c09c3449f5b4 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index c8f0f2772a..76feba8449 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -12,7 +12,7 @@ A shared mechanism must remain a UI concern rather than a model tool or agent-lo ## Decision -`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front door; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. +`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front end; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. ### Registry contract @@ -50,7 +50,7 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing the TUI. - **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. -- **Put the registry in the core agent spine** — rejected because UI-less front doors do not consume it, while TUI can compose it explicitly. +- **Put the registry in the core agent spine** — rejected because UI-less entry points do not consume it, while TUI can compose it explicitly. - **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. - **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment. - **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. @@ -68,4 +68,4 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - Input metadata is limited to an unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later registry or consumer extension. - Generic command output is live-only and is not reconstructed after TUI restart. - Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal. -- The ACP automation server, headless CLI, and JSON-RPC SDK front doors do not expose the command plane; only TUI consumes it. +- The ACP automation server, headless CLI, and JSON-RPC SDK entry points do not expose the command plane; only TUI consumes it. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index a8f7a3d1e7..e89cb79ba1 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -12,7 +12,7 @@ TUI 拥有斜杠命令。如果命令名、帮助文本、自动补全、分派 ## 决策 -位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的入口旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 +位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的前端旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 ### 注册表约定 @@ -50,7 +50,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改 TUI。 - **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 -- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 前端不消费它,而 TUI 可以显式组合它。 +- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 运行入口不消费它,而 TUI 可以显式组合它。 - **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 - **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。 - **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 @@ -68,4 +68,4 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - 输入元数据仅限非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续注册表或消费方扩展。 - 通用命令输出仅实时存在,TUI 重启后不会重建。 - 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 消费它。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 运行入口不暴露命令平面;只有 TUI 消费它。 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml index ee932dd716..461e95c391 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-21-local-instruction-overlay.md -2026-07-21-local-instruction-overlay.md: 3c7b2141b0515b5e667be4add6ad765e26c88cd8 +2026-07-21-local-instruction-overlay.md: fb5f916d426595a80bbbaa4192e4a3975b922b8a 2026-07-21-local-instruction-overlay.zh.md: c97ed04607f497d829da0e904c248836252c73f7 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md index 3c7b2141b0..fb5f916d42 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md @@ -26,7 +26,7 @@ The base and local candidates in one directory must stay independent across base **Keep it opt-in through `instructionFileCandidates`.** Rejected: one directory has a single winner, so a `.local.` name added to that list shadows the base file rather than supplementing it. The packages guidance to keep opt-ins out of shipped defaults is outweighed here by strong prior art and the user-facing expectation that `.local.` files are always read. -**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever front door remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. +**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever entry point remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. **Reuse the bare directory as the scope key for base and local files.** Rejected: base and local files in one directory would collide in every scope-keyed map, so a change to one would suppress or overwrite the other. A distinct scope key per candidate keeps them independent without widening the persisted metadata shape. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 626d98b3c8..da84a269ed 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 78017b14a806f8e609a85e340094cd2a349d47e1 -2026-07-24-web-session-model-selector.zh.md: 87a342721f5e47f5bcedfafb578eb6916101d2f5 +2026-07-24-web-session-model-selector.md: e6a96ac62f69a3bd312f61cc920caa259d2dc5b0 +2026-07-24-web-session-model-selector.zh.md: 5a0245359d69ac6e59a20dc3276b9411c4b25e23 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 78017b14a8..e6a96ac62f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -6,7 +6,7 @@ English | [中文](2026-07-24-web-session-model-selector.zh.md) ## Problem -The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front doors. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. +The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front ends. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 87a342721f..5a0245359d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前门中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 +Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前端中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 ## 决策 Web Host 为每个新建或恢复的 Agent 安装 `ModelSelection`。如果会话已经使用过模型,提供方/模型/推理(reasoning)选择来自最新的 `request/header`;否则来自 `ctx.agentDefaultModel`。`session.selectModel` 会赋值会话级选择,提示词组装则将它与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的选择通过完整的 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录的 surface,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前端对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录所在的前端,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整的 `ModelSelection`、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 选择。失败时保留先前的选择和可用分组。 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index fb9f46c641..0532b639eb 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 -2026-08-01-pwsh-tool-and-executor.zh.md: c59ba8e3e6c68d48d310861325c74dc6cec8e5c3 +2026-08-01-pwsh-tool-and-executor.md: 855d8e5db8a78e7e798061724d89fde31b28e975 +2026-08-01-pwsh-tool-and-executor.zh.md: dcf5c53cd6681be07adafef92d556b400c116736 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index 7206f8ffe6..855d8e5db8 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -6,30 +6,30 @@ English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md) ## Problem -The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry. +The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool is also larger than a Windows-first profile strictly needs — the persistent-PTY twin in particular is bash-shaped surface the `pwsh` tool still does not carry. The original minimal profile also left out background tasks and sandbox escalation: background arrived with the [parity decision](2026-08-02-pwsh-tool-bash-parity.md), and the sandbox surface (denial rendering plus `sandbox_permissions` escalation) arrived with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the minimal tool was sized for the danger-full-access Windows posture, and that premise ended when the sandbox PR re-enabled confinement and approval on Windows. ## Decision Two new packages under `packages/bash/`: - **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. -- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus the Windows-specific ConstrainedLanguage and named-pipe contracts in the tool description. The parity decision supersedes this note's minimal-profile tool description. Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. -The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [the Windows pwsh default decision](2026-08-01-windows-pwsh-default.md). ## Alternatives considered **Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation. -**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest. +**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the model-visible contract is the dialect itself (paths, variables, exit facts differ), so a dialect parameter would either churn the schema conditionally or force one tool to teach two dialects; the separate twin keeps the model contract honest — and carries the shared surfaces (background, sandbox, rendering) by mirroring rather than by sharing an implementation. **Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default. ## Consequences - The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. -- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage and named-pipe boundaries precisely. - Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. - The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index c59ba8e3e6..dcf5c53cd6 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -6,30 +6,30 @@ Status: implemented ## 问题 -harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。 +harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具也大于 Windows 优先画像的严格所需——尤其持久 PTY 孪生是 `pwsh` 工具至今仍不背负的 bash 形状表面。最初的最小画像也没有后台任务与沙箱升级:后台随 [parity 决策](2026-08-02-pwsh-tool-bash-parity.md) 到来,沙箱面(拒绝渲染加 `sandbox_permissions` 升级)随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 到来——最小工具当初按 danger-full-access 的 Windows 姿态裁剪,这一前提在 sandbox PR(Pull Request)于 Windows 上重新启用隔离与审批时终结。 ## 决策 在 `packages/bash/` 下新增两个包: - **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 -- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,约定是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,约定是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,bash 的 marker/截断渲染故事(干净退出不产生 marker),以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 与 named-pipe 约定。parity 决策取代了本 note 的最小画像工具描述。 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 -本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——已落地为 [Windows 默认 pwsh 决策](2026-08-01-windows-pwsh-default.md)。 ## 备选方案 **给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。 -**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型约定保持诚实。 +**给 `dsh-tool-bash` 增加方言参数。** 否决:模型可见约定本身就是方言(路径、变量、退出事实都不同),因此方言参数要么让 schema 按条件翻动,要么逼一个工具教两种方言;独立的孪生让模型约定保持诚实——并以镜像而非共享实现的方式携带共享表面(后台、沙箱、渲染)。 **现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。 ## 后果 - bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范约定一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 -- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 约定。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台、后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 约定、沙箱拒绝/升级词汇,以及 ConstrainedLanguage 与 named-pipe 边界。 - Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 - CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..49b713f534 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49 +2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..f0da86e52b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,44 @@ +# Agent Note: Windows defaults to pwsh + +Status: implemented + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior (hardcoded `bash -c` argv, process-group semantics); the model-facing bash tool teaches the bash dialect. The Windows-native foundation shipped in the [pwsh executor and tool decision](2026-08-01-pwsh-tool-and-executor.md) — a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but shipped compositions still mounted the bash stack on Windows, so a Windows host without a shim could not run the shipped shell. + +## Decision + +Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. + +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. + +The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. + +**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. + +**Keep fs path-rule confinement on Windows (`sandbox-policy` + `fs-sandbox` without OS runners).** Rejected: the shell is the model's primary tool and unconfined on Windows, so fs-only path rules are trivially bypassable and would overstate the boundary; the honest posture is full degradation to danger-full-access. + +**Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. + +## Consequences + +- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. +- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. + +## Verification + +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service). +- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..41a6429eab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,44 @@ +# Agent Note: Windows 默认改用 pwsh + +Status: implemented + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为(硬编码 `bash -c` argv、进程组语义);面向模型的 bash 工具教的是 bash 方言。Windows 原生基础已随 [pwsh 执行器与工具决策](2026-08-01-pwsh-tool-and-executor.md) 交付——`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但交付组合在 Windows 上仍然挂载 bash 栈,没有垫片的 Windows 主机跑不了交付的 shell。 + +## 决策 + +启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 + +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 + +pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 + +**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。 + +**在 Windows 上保留 fs 路径规则限制(无 OS runner 的 `sandbox-policy` + `fs-sandbox`)。** 否决:shell 是模型的主工具且在 Windows 上不限权,仅限 fs 的路径规则一行命令即可绕过,会夸大边界;诚实的姿态是完全退化到 danger-full-access。 + +**交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 + +## 后果 + +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 +- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 + +## 验证 + +- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app)断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。 +- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index be7a6a1962..4c1833845a 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: d61dd6f21223121973520014c44e2e5846e7387a -2026-08-02-pwsh-tool-bash-parity.zh.md: 120f66b1d9c713d5e3b71575186fc2194b0718a5 +2026-08-02-pwsh-tool-bash-parity.md: 79a09cb4c9698660faff18cf9ae016bec0bce227 +2026-08-02-pwsh-tool-bash-parity.zh.md: 3e01fd4447fbf047b7cbb3949e708094a6c06c09 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index d61dd6f212..79a09cb4c9 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -10,13 +10,13 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Decision -`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior: +`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, and its model-visible text describes exactly that behavior: - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. - **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. -- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. +- **Out of scope, unchanged**: persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). Sandbox escalation shipped later with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the pwsh tool now carries the sandbox denial rendering and the same-turn `sandbox_permissions` escalation surface, plus the Windows ConstrainedLanguage contract in its description. The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. ## Alternatives considered @@ -28,7 +28,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Consequences -- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer. +- The bash and pwsh tools are now behaviorally interchangeable for foreground, background, and sandboxed shell work (the sandbox surface arrived with the Windows ACL sandbox decision), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. - Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index 120f66b1d9..3e01fd4447 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为: +`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,其模型可见文本精确描述这一行为: - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 - **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 -- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 +- **范围外,不变**:持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。sandbox 升级随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 稍后交付——pwsh 工具现在携带 sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面,外加其描述中的 Windows ConstrainedLanguage 契约。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 ## 备选方案 @@ -28,7 +28,7 @@ Status: implemented ## 后果 -- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书。 +- bash 与 pwsh 工具在前台、后台与沙箱化 shell 工作上行为可互换(sandbox 面随 Windows ACL sandbox 决策到来),pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 - 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml new file mode 100644 index 0000000000..24b04ea72f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.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-05-per-agent-tool-presentation.md +2026-08-05-per-agent-tool-presentation.md: 348f7ab0a26e9b39057dbac885304e0d52e0b1fb +2026-08-05-per-agent-tool-presentation.zh.md: 4920ee6eb061d44934bfc9f5176e244f5aac8553 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md new file mode 100644 index 0000000000..348f7ab0a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -0,0 +1,46 @@ +# Agent Note: Per-agent tool presentation, and the `code` preset + +Status: implemented + +English | [中文](2026-08-05-per-agent-tool-presentation.zh.md) + +## Problem + +Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on. + +The naive reading of "move tools down to the agent plane" does not work. `ctx.tools` has host-plane consumers that cannot follow it: `dsh-agent-loop` reads the registry's private scheduler seam, `dsh-apiproxy` reads its presenters to render tool cards, and every tool plugin registers into it. By the stack's own rule — a service moves into a preset only when ALL of its consumers move with it — the registry stays where it is. + +## Decision + +Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there. + +`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. + +Two consequences fell out and are load-bearing: + +- **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need. +- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. + +The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. + +The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act. + +## Alternatives considered + +**A second `ToolRegistry` inside the preset's isolate realm.** Rejected: `dsh-agent-loop` resolves the registry once from the host context through a private symbol, so a per-agent registry would be invisible to the scheduler. Making the loop registry-per-agent is a far larger change than making one field scope-aware. + +**A top-level key in the preset's own YAML.** Rejected for the reason preset display metadata went to a separate `preset.yml`: the composition is a top-level list of plugin rows and cannot carry sibling keys. + +**Naming the package `dsh-tool-mode`.** Rejected by a gate, correctly. `gen-tool-catalog` globs `packages/*/tool-*` and requires every match to publish a model-facing tool schema, because that prefix means "ships a tool" in this repo. This row ships none. + +**Registering the SDK section unconditionally from the constructor.** Rejected after trying it: `renderPrompt` drops empty sections but `PromptAssembly.sections` retains them, so every native deployment would carry a `tools:sdk` entry rendering nothing, and two existing assertions on that list would have had to be weakened to accommodate it. + +**Sharing `standard`'s composition by include.** Rejected per the stack's own convention: `cordis` already duplicates `standard`, and a preset's value is that its whole composition is readable in one file. The cost — a third copy of ~240 lines that must move together — is real and is the strongest argument for a future include mechanism. + +## Consequences + +Two sessions in one process can now present differently, so "which tools does the model see" is no longer answerable from the deployment config alone; it requires the agent. Every diagnostic that quotes a mode now quotes the scope's, not the service's. + +`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `code` preset. + +The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no code-mode preset; the shipped Web overlay carries one, the base composition does not. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md new file mode 100644 index 0000000000..4920ee6eb0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -0,0 +1,46 @@ +# Agent Note: 按 agent 的工具呈现方式,以及 `code` 预设 + +Status: implemented + +[English](2026-08-05-per-agent-tool-presentation.md) | 中文 + +## Problem + +agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。Code Mode——一个 `run_code` 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 Code Mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。 + +「把 tools 下沉到 agent 平面」这个字面读法行不通。`ctx.tools` 有一批跟不下来的宿主平面消费者:`dsh-agent-loop` 读它私有的调度器 seam,`dsh-apiproxy` 读它的 presenter 来渲染工具卡,每个工具插件都往里注册。按本 stack 自己的规则——只有**所有**消费者一起下沉,服务才能下沉——注册表必须留在原地。 + +## Decision + +把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。 + +`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 + +有两个随之而来的结果,且都是承重的: + +- **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。 +- **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 + +SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 + +preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 + +## Alternatives considered + +**在 preset 的 isolate realm 里再起一个 `ToolRegistry`。** 否决:`dsh-agent-loop` 通过一个私有 symbol 从宿主上下文一次性解析注册表,因此按 agent 的注册表对调度器不可见。把 loop 改成按 agent 解析注册表,远比把一个字段变成 scope 感知的改动大。 + +**在 preset 自己的 YAML 里加一个顶层键。** 否决,理由与 preset 展示元数据落到独立 `preset.yml` 相同:组装是一个顶层的插件行列表,装不下并列的键。 + +**把包命名为 `dsh-tool-mode`。** 被一道 gate 否决,而且它是对的。`gen-tool-catalog` 以 `packages/*/tool-*` 通配,并要求每个命中项发布一个面向模型的工具 schema——因为在本仓库里这个前缀就意味着「带工具」。而这一行不带任何工具。 + +**在构造函数里无条件注册 SDK 段。** 试过之后否决:`renderPrompt` 会丢弃空段,但 `PromptAssembly.sections` 会保留它们,于是每个 native 部署都将携带一个什么也不渲染的 `tools:sdk` 条目,而两处既有断言不得不为此放宽。 + +**用 include 共享 `standard` 的组装。** 按本 stack 自己的惯例否决:`cordis` 已经复制了一份 `standard`,而 preset 的价值恰在于整份组装能在一个文件里读完。代价——第三份约 240 行、且必须同步演进的副本——是真实的,也正是未来引入 include 机制最有力的论据。 + +## Consequences + +同一进程内的两个会话现在可以有不同的呈现方式,因此「模型看到哪些工具」不再能只凭部署配置回答,必须给出 agent。凡是引用模式的诊断信息,现在引用的都是该 scope 的,而不是服务的。 + +`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;`web-agent-presets.spec.ts` 对随附的 `code` 预设同时断言了这个区分的两侧。 + +随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 code 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index 6f924866eb..8d36a8d9db 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: ba92f482e957acd00792a2a8053716047a4da27d -2026-08-05-pwsh-ui-bash-parity.zh.md: 693b3fc26e632718ea2135282d63daa32e63e1c5 +2026-08-05-pwsh-ui-bash-parity.md: a59ae95ab64ae28babc913958a1a6ba424898210 +2026-08-05-pwsh-ui-bash-parity.zh.md: 8e065640e56bf6d6b92e62cb746fa9dd53f6f2b5 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index ba92f482e9..a59ae95ab6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 693b3fc26e..8e065640e5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml new file mode 100644 index 0000000000..1935c6de2e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.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-queue-steer-all-gesture.md +2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76 +2026-08-06-web-queue-steer-all-gesture.zh.md: fb36852f66a86408af12df59001230b2024eccaf diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md new file mode 100644 index 0000000000..e546f68647 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -0,0 +1,33 @@ +# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter + +Status: implemented + +English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md) + +## Problem + +While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything". + +## Decision + +Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary. + +The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. + +The same computed availability gate drives discovery: while the draft is empty, the input is unlocked and not in a transient machine lock, the command menu is closed, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence, and the steer hint deliberately outranks the plan-mode placeholder while available (the gesture genuinely works in that window). + +## Consequences + +One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The gesture and its placeholder share one presentation-layer gate, while the hub re-checks the snapshot at execution time, so the client gate remains advisory and the host remains authoritative. + +## Related + +The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision. + +## Alternatives considered + +- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op. +- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. +- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. +- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. +- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md new file mode 100644 index 0000000000..fb36852f66 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话 + +Status: implemented + +[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文 + +## Problem + +主会话运行时,用户用普通 Enter(或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮;而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝,Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。 + +## Decision + +空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`(pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。 + +该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 + +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定且不处于瞬态机器锁(adjudicating/submitting)、命令菜单未打开、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先;可用时 steer 提示会刻意优先于 plan 模式 placeholder(该窗口内手势确实可用)。 + +## Consequences + +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。手势及其 placeholder 共用一个呈现层门控;hub 在执行时会重新读取快照,因此客户端门控仍只是建议性的,host 仍是权威。 + +## Related + +逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。 + +## Alternatives considered + +- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。 +- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 +- **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 +- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 +- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 684931decb..de5133b527 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: ed7e7a424d2cacadea890506fd9150ffdf7a993c -2026-08-07-default-model-follows-the-picker.zh.md: 523c6f917dedf21c726ce5631226ba545126c757 +2026-08-07-default-model-follows-the-picker.md: 2a3ada55486345c0f58f0767bed5a93ecba04b88 +2026-08-07-default-model-follows-the-picker.zh.md: 08fecc6ec9b177f6424ada3172c67018ac72baea diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index ed7e7a424d..2a3ada5548 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -6,13 +6,13 @@ English | [中文](2026-08-07-default-model-follows-the-picker.zh.md) ## Problem -A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent front doors cannot share it without depending on Host or duplicating state. +A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent entry points cannot share it without depending on Host or duplicating state. Reasoning effort makes the persistence shape significant: a model selection without an effort must clear a stored effort, or the next Agent may apply an effort that its selected model does not accept. ## Decision -`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is front-door-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. +`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 523c6f917d..08fecc6ec9 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的前门只有依赖 Host 或复制状态才能共享它。 +会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的入口只有依赖 Host 或复制状态才能共享它。 推理强度使持久化形态成为约定的一部分:不含强度的模型选择必须清除已存强度,否则下一个 Agent 可能会采用所选模型不接受的强度。 ## 决定 -`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定前门,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 +`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;缺席值由此清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index f4e308c8f2..730f57e681 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: 566eeb5b2a09a0d07d72a68e4a5f449d2822e708 -2026-08-08-dsh-run-headless-command.zh.md: 177410e783a37026940829da5f81343ddc61cb29 +2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed +2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md index 566eeb5b2a..ed095f4077 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` is a distinct `DshInvocation` member. The generic profile invocation carries no task state and accepts no positional arguments. Both dispatch paths use `runProfile`: profile boot omits `task`, while `run` supplies it. A one-shot profile without `headless-runner` fails through the composed-row check, and profile boot containing that row without a task points to `dsh run --profile ""`. -The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. +The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. The `run` verb belongs only to one-shot task execution. Application-file launch requires a distinct command name. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md index 177410e783..89d54e3557 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不携带任务状态,也不接受位置参数。两条分派路径都使用 `runProfile`:profile 启动省略 `task`,而 `run` 提供该字段。缺少 `headless-runner` 的一次性 profile 会触发组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 -[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 +[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 `run` 动词只负责一次性任务执行。应用文件启动需要不同的命令名。 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 005b3cad67..b44a25790d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 74d9f01f191005db6d3d283a3c56f5ee664447f8 -2026-08-08-user-explicit-skill-invocation.zh.md: 0f7c9e1261dda796d988c17ddf7199b74be4133e +2026-08-08-user-explicit-skill-invocation.md: a7c2c15703af318cb4112f2d3dfda698bc5e3bc2 +2026-08-08-user-explicit-skill-invocation.zh.md: a8d685e5eb12766bebddebb7f5993579cf08531e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 74d9f01f19..a7c2c15703 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -10,14 +10,14 @@ A `disable-model-invocation: true` skill is user-only by design: it never enters ## Decision -User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every entry point: - `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. - Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. - The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. - The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. -Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every entry point from implementing recognition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index 0f7c9e1261..a8d685e5eb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -10,14 +10,14 @@ Status: implemented ## 决策 -用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种运行入口一致: - `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall(瀑布式事件)会把携带目录的列表交给它来扩展。 - 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 - 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 - 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,由注入和 `skill` 工具结果共用,二者内容逐字相同,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 -同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种运行入口免于自行实现识别。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml new file mode 100644 index 0000000000..c9d678da0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +2026-08-08-windows-acl-restricted-token-sandbox.md: 7e8f229269233d9ac9baa65241ca02a4cf4c3f7c +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: eeb346b228b3559f487448e5d4ec525b7bb89525 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md new file mode 100644 index 0000000000..7e8f229269 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -0,0 +1,43 @@ +# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer + +Status: implemented + +English | [中文](2026-08-08-windows-acl-restricted-token-sandbox.zh.md) + +## Problem + +The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — `read-only` (zero writes) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading. + +## Decision + +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. + +## How the restriction works (why no new identity) + +The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. + +## Alternatives considered + +### Why not mxc (Microsoft xContainer)? + +Two disqualifiers. First, the OS floor is too new: the [mxc OS-version policy](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) sets the product floor at Windows 11 24H2 (build 26100), and the BaseContainer tier (T1, `Experimental_CreateProcessInSandbox`) exists only on 25H2+ (build 26600+) with the OS feature enabled — on every supported release at or below 25H2 the filesystem policy falls back to T3, AppContainer plus host-side DACL ACE augmentation. Second, supporting arbitrary-path reads under either tier means granting read access by writing ACLs over every path the child may read: a model that reads the whole workspace and arbitrary files would require wholesale host DACL mutation — a standing side effect and a cost a write-only restriction does not need. + +### Why not AppContainer? + +An AppContainer token carries no ambient read access: every readable path must be pre-granted through capabilities or explicit ACEs, so arbitrary-path reads — the harness's read model — are unsupported without the same wholesale grants. The restricted token needs no read grants at all: it intersects write access only. + +### Why not landstrip? + +The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem. + +## Consequences + +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. + +## Testing + +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). + +## Related + +The [pwsh executor decision](2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md new file mode 100644 index 0000000000..eeb346b228 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer + +Status: implemented + +[English](2026-08-08-windows-acl-restricted-token-sandbox.md) | 中文 + +## Problem + +[沙盒决策](2026-07-06-sandbox.md)把 `PLATFORM_CHAINS.win32` 留空,交付的 Windows profile 因为没有可用的隔离执行器而退化为 danger-full-access。win32 档必须实现沙盒词汇表承诺的两个文件效果模式——`read-only`(零写入)与 `workspace-write`(仅工作区根目录加后端定义的临时区域可写)——同时保持读、网络与进程可见性不受影响,因为所有模式都允许读取。 + +## Decision + +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 + +## How the restriction works (why no new identity) + +身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过孤儿 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 + +## Alternatives considered + +### 为什么不选 mxc(Microsoft xContainer)? + +两个否决理由。其一,OS 版本要求太新:[mxc 的 OS 版本支持文档](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md)把产品下限设在 Windows 11 24H2(build 26100),而 BaseContainer 档(T1,`Experimental_CreateProcessInSandbox`)只在 25H2+(build 26600+)且启用 OS feature 时存在——在 25H2 及以下的所有受支持版本上,文件系统策略都会回退到 T3,即 AppContainer 加宿主侧 DACL ACE 改造。其二,在任一档下支持任意路径读都意味着要为子进程可读的每个路径写 ACL 授予读权限:模型要读整个工作区和任意文件,就需要全盘改写宿主 DACL——对只做写限制的需求而言,这是不必要的驻留副作用与代价。 + +### 为什么不选 AppContainer? + +AppContainer 令牌没有环境读访问:每个可读路径都必须预先通过 capability 或显式 ACE 授予,因此任意路径读——harness 的读模型——在不做同样的全盘授予时无法支持。受限令牌完全不需要读授予:它只对写访问做交集。 + +### 为什么不选 landstrip? + +[landstrip 评估](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)在实现前已被否决(未经实战检验;自建 launcher 方案胜出),且其 Windows 后端是 AppContainer 形态,继承同样的任意路径读问题。 + +## Consequences + +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 + +## Testing + +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 + +## Related + +[pwsh 执行器决策](2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml index ae738e3fad..95b38715c2 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md -2026-07-05-uniform-agent-note-format.md: 06082251c1b96c90ed470d84224662e00e29791b +2026-07-05-uniform-agent-note-format.md: c05d81c700b81f0d619173a261f405b1cc039df1 2026-07-05-uniform-agent-note-format.zh.md: 3daa686b64b31ee2638b25dd4c42e5d8172f1d97 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md index 06082251c1..c05d81c700 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md @@ -23,7 +23,7 @@ The whole corpus was normalized in the same change that defined the format — t - **A bare `# ` H1** — rejected: the `Agent Note: ` prefix self-describes the genre when a file is read outside its tree, and the format gate prevents it from drifting. - **`## What we give up` as the implemented closer** (the README's own phrase for what an Agent Note records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well. - **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and nineteen files show what convention alone achieves here. -- **A standalone `FORMAT.md` contract file** — rejected because one front door carrying layout, classification, and format is easier to discover and maintain than two contract files. +- **A standalone `FORMAT.md` contract file** — rejected because one entry point carrying layout, classification, and format is easier to discover and maintain than two contract files. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml index ce16a025cc..5c5386b2dd 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-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 .agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md -2026-07-19-remove-generated-agent-note-index.md: ee85ec0757d5924f5784c43a50003eb96e0a9531 +2026-07-19-remove-generated-agent-note-index.md: 652ac72afe69284240667e61af1f3fbfcb182ddb 2026-07-19-remove-generated-agent-note-index.zh.md: 6e1967fbde0c6ac59bbc3a184dad68c989efadbc diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md index ee85ec0757..652ac72afe 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md @@ -12,7 +12,7 @@ The centralized chronological list adds little discovery value beyond browsing t ## Decision -The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated front door and contract, while ordinary tree navigation and repository search provide discovery. +The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated entry point and contract, while ordinary tree navigation and repository search provide discovery. `scripts/agent-note-tree.ts` owns the closed lifecycle/class sets and structural walker. `verify-agent-note-classification` validates that tree and rejects the legacy homes and a root `INDEX.md`; it does not render or freshness-check a centralized list. diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index d85aa1fe0c..61942db772 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e +2026-07-22-product-first-root-readme.md: 00f6084da9e83135c881abfef21b0b249cd4e30a 2026-07-22-product-first-root-readme.zh.md: 8ef6f4b99ca2c935183a225b6357d2d128edb3b0 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 32542a4501..00f6084da9 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-product-first-root-readme.zh.md) ## Problem -The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. +The root README is the repository's product entry point. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. ## Decision @@ -26,7 +26,7 @@ Detailed package and service inventories remain at their owning documentation. T **Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides. -**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. +**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer entry point have different navigation and maintenance needs. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index b124dbfd9e..e305a9d10e 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: f429f3d41c1f152e83faeb12c379d221627e767f -2026-08-03-package-anchored-subsystem-pages.zh.md: 3a56fa39357591197606a28b49cf0eac8f58963e +2026-08-03-package-anchored-subsystem-pages.md: 2a47f35e9d3755286bed7d42f59fd21e30f9148d +2026-08-03-package-anchored-subsystem-pages.zh.md: 53216a430b979f0612f256aaec9774b88fe14bbd diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index f429f3d41c..2a47f35e9d 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -14,7 +14,7 @@ Every `docs/subsystems/` page anchors to the package or package group that decla Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links). -Every `packages/<group>/README.md` pair is a thin front door in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. +Every `packages/<group>/README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so a page added by a later PR (or absorbed in a merge) cannot silently miss the index. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index 3a56fa3935..53216a430b 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -14,7 +14,7 @@ Status: implemented 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给真正服务本地或 vendored 的形状。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.md);core.md 概括并链接)。 -每个 `packages/<group>/README.md` 配对都是统一形状的轻薄门面:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 +每个 `packages/<group>/README.md` 配对都是统一形状的精简入口:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 [子系统 README](../../../../docs/subsystems/README.md) 在两个语言侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每页一行表格,因此后续 PR 新增(或合并吸收)的页面无法悄悄缺席索引。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index d09614a7fa..66e573773b 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: 29da3025251f9826c5780493bb2725b114a40601 +2026-07-23-acp-automation-only-protocol.md: 56c433acf59d3a0c4b5c8b6605e422d3c4efede2 2026-07-23-acp-automation-only-protocol.zh.md: bf326ce9f6fea8c55b842bc61e3d8521032ea4d1 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 29da302525..56c433acf5 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -46,7 +46,7 @@ Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiat ## Consequences -ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor front door. +ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point. Automation clients receive complete committed text rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index 73c3fb6e55..bcf543a344 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: f048a04db02b582d038ebf549999c0fda50e30d3 +2026-08-04-remove-tui-package.md: 19cc7d1a89a55bb57a69b9fce301f48f89384acd 2026-08-04-remove-tui-package.zh.md: be18cbd33cd4a2bb592de4e7986b2786da272d0f diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index f048a04db0..19cc7d1a89 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -8,7 +8,7 @@ English | [中文](2026-08-04-remove-tui-package.zh.md) Removing the implicit `dsh` terminal application left `@deepseek-ai/dsh-tui` without a shipped composition. The package still carried a terminal renderer, interactive command and question adapters, extension overlays, snapshot fixtures, a patched `pi-tui` dependency, and SDK scaffolding that advertised TUI as a supported application interface. Keeping that surface required maintaining a product-sized frontend whose only remaining consumer was the project generator itself. -The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI front doors, while the SDK continued to offer a terminal choice that no example or product command exercised. +The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI entry points, while the SDK continued to offer a terminal choice that no example or product command exercised. ## Decision @@ -34,6 +34,6 @@ Repository searches and generated catalogs contain no TUI package, dependency pa ## Consequences -DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web front doors. +DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points. The provider-neutral command, user-interaction, approval, tool-presentation, PTY, and session-projection capabilities remain available to other hosts. Reintroducing a terminal frontend requires a named product or deployment, an explicit package boundary, a concrete interaction provider, and assembled lifecycle and transcript acceptance for that frontend. diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml new file mode 100644 index 0000000000..94466cd530 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.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-08-copy-only-preset-authoring.md +2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde +2026-08-08-copy-only-preset-authoring.zh.md: dc2d7924cb0fd9363efa9387bc8ba68bf11af5d7 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md new file mode 100644 index 0000000000..c16518b087 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -0,0 +1,30 @@ +# Agent Note: Copy-only preset authoring, and the way into a preset's files + +Status: implemented + +English | [中文](2026-08-08-copy-only-preset-authoring.zh.md) + +## Problem + +The agent-preset settings page carried a web YAML editor: `agentPreset.write` accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own `entryListSchema` — whose dialect includes `!!js`, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-vs-roster races the section had to defend against. + +## Decision + +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers). + +## Consequences + +- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target. +- With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart. +- A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability. +- `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards. + +## Load-bearing details + +- **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see. +- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`. +- **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd. + +## Alternatives considered + +Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md new file mode 100644 index 0000000000..dc2d7924cb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口 + +Status: implemented + +[English](2026-08-08-copy-only-preset-authoring.md) | 中文 + +## Problem + +agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` 接收任意组装文本,页面是一个没有补全、高亮或 diff 的文本域,形状检查依赖 Loader 自己的 `entryListSchema`——其方言含 `!!js`,所以「过了形状检查的文本」在下一次挂载时仍是任意代码。作为编辑器很弱,作为能力很宽,还是该分区不得不防御的「编辑器 vs 名单」竞态的来源。 + +## Decision + +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供卡片以文本展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。 + +## Consequences + +- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。 +- 编辑器移除后,手改 `agent.cordis.yml` 成为**唯一**的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.md),已就地更新)。没有它,改过的文件要等进程重启才生效。 +- 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。 +- `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument` 与 `remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。 + +## Load-bearing details + +- **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError`,`errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。 +- **展示的路径是响应方向的披露,且钉在环回。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`。 +- **e2e lane 钉死 `nativeOpen: false`**(`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。 + +## Alternatives considered + +保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 9a88d14722..217265168d 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: c1153f5e9fcc89585e926f8f088829c849d1f6c1 +2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index c1153f5e9f..403e01f94c 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two front doors also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml deleted file mode 100644 index 5be65aaf06..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.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/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 64d713aecda60d8747199aff5334a80bf1867d92 -2026-08-01-windows-pwsh-default.zh.md: 74ba6c43dea984384a9b7dd2675b681ee025d6ca diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md deleted file mode 100644 index 64d713aecd..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows defaults to pwsh (roadmap) - -Status: proposed - -English | [中文](2026-08-01-windows-pwsh-default.zh.md) - -## Problem - -The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. - -## Proposal - -Two follow-up stages, each independently shippable. The bash-tool parity twin shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. - -1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. -2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed. - -The stages are ordered by dependency only where one exists: the rendering stage shipped first with the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) because it is platform-independent and its keyless web lane runs on any host, while the Windows default composition remains the only unshipped stage. Nothing in this proposal changes POSIX behavior. - -## Alternatives considered - -**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. - -**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. - -**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. - -## Acceptance criteria - -- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. -- POSIX hosts are byte-for-byte unaffected (same roster, same executor). -- The shipped-composition e2es assert the platform-gated roster on both families. -- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot). - -## Risks - -- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. -- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. -- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md deleted file mode 100644 index 74ba6c43de..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows 默认改用 pwsh(路线图) - -Status: proposed - -[English](2026-08-01-windows-pwsh-default.md) | 中文 - -## 问题 - -harness 交付的执行配置在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 界面按照 bash 风格的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 - -## 提案 - -两个阶段,各自可独立交付。bash 工具对等孪生已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在除 sandbox 接口外,在前台与后台工作方面均与 `tool-bash` 对齐,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装后形态的 keyless 应用快照。 - -1. **Windows 默认组合**——交付的 CLI(命令行界面)组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 -2. **pwsh GUI 渲染**——Web 界面使用 bash 风格的终端呈现来渲染 pwsh 调用(带胶囊状退出状态标签的终端卡片),与 bash 终端卡片相对应。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付;TUI 已移除,不再有对应的终端界面。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 信息)仍无人认领。 - -各阶段仅在有依赖关系时排序:渲染阶段已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 先行交付(平台无关,其 keyless web 通道可在任意宿主运行),而 Windows 默认组合仍是唯一未交付的阶段。本提案不改变任何 POSIX 行为。 - -## 备选方案 - -**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 - -**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于会在审批/PTY 界面上显现的组合决策。 - -**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 - -## 验收标准 - -- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 -- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 -- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 -- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地(TUI 的移除意味着不再有可供快照测试的终端界面)。 - -## 风险 - -- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell 钩子的钩子桥接、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 -- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 暴露出来;这些覆盖必须按阶段扩展,不能想当然地认为已经具备。 -- **渲染约定**——与 bash 风格一致的终端呈现已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 信息)仍是一项需要快照覆盖的 UI 设计决策,随阶段 1 一起延期。 diff --git a/.gitignore b/.gitignore index 4d3e1305d7..3d0fd8e322 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ oxlint-contract-*.ts .humanize/ tmp/ .claude/commands/ +.claude/launch.json .claude/settings.json .vscode/ .DS_Store diff --git a/AGENTS.md b/AGENTS.md index ff31eb0b65..7e481c62b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/ workflow/ workflow capability + worker-thread provider + tool Consumer todo/ todo_write tool plan/ plan mode as logged state + preset/ per-session agent composition from preset cordis.yml files guard/ loop-hygiene + tool-timeout plugins self-modification/ the agent inspects/mounts its own plugins hooks/ Claude Code/Codex hook bridges + wire-protocol library diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml new file mode 100644 index 0000000000..65d2716458 --- /dev/null +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -0,0 +1,240 @@ +# The `code` agent preset: the standard coding agent, presented as Code Mode. +# +# Everything in `standard` is here unchanged. What is added is the `tool-mode` +# row: instead of one tool call per action, the model writes a TypeScript +# program against a generated SDK and `run_code` executes it, so a sequence +# that would be five round trips becomes one. +# +# The registry itself stays on the host plane — the agent loop's scheduler and +# the API proxy's presenters are its consumers — so what this preset owns is +# the PRESENTATION of that registry for this agent alone. Native sessions run +# beside this one in the same process, each seeing its own catalog. +# +# This file is an AGENT-PLANE composition. It is mounted under one agent's +# scope context, so every tool and prompt section it registers belongs to that +# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`) +# keeps everything a preset must not own: the registries themselves, the +# sandbox and approval stack, persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global +# rather than per-session and the second session mounting this preset collides +# with the first; `dsh-agent-presets` rejects that at mount. `true` means an +# entry-local realm — one private instance per mounted session, which is the +# default this deployment wants. A shared label would instead pool one instance +# across every session naming it. + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── skills ────────────────────────────────────────────────────────────────── + +# The skill REGISTRY lives in the host composition and is layered per scope: +# these rows register into THIS preset's layer of it, so they need no realm. +# `skill-local` contributes local-root discovery for agents on this preset, and +# `tool-skill` gives them the catalog and loader; the merged catalog also +# carries whatever the deployment registered globally (repository plugins). +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +# Only the model-facing tool. The goal SERVICE, its session driver, and the +# `/goal` command stay on the host plane: the Gateway serves the goal domain as +# Remote endpoints whose receiver comes from a generated descriptor, so it +# resolves `goals` on the host and an entry-local realm here would hide it. The +# registry is keyed by session anyway, so one host instance serves every +# session. What a preset chooses is whether its agent can call the goal tool. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + toolResultPrune: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + +# ── presentation ──────────────────────────────────────────────────────────── + +# Code Mode for this agent alone. The row waits for the host's `codeRuntime` +# rather than assuming it: a deployment that composes no TypeScript runtime +# fails this preset at mount, naming this id, instead of at the first request. +- id: tool-mode + name: '@deepseek-ai/dsh-agent-tool-mode' + config: + mode: code diff --git a/apps/cli/config/agent-presets/code/preset.yml b/apps/cli/config/agent-presets/code/preset.yml new file mode 100644 index 0000000000..f3426e52f4 --- /dev/null +++ b/apps/cli/config/agent-presets/code/preset.yml @@ -0,0 +1,3 @@ +name: 代码模式 +description: 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 +order: 2 diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml new file mode 100644 index 0000000000..f2cdeea159 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -0,0 +1,240 @@ +# The `cordis` agent preset: the standard coding agent, plus the ability to +# read and write the runtime it is running in. +# +# It exists so a person can ask an agent to author another agent. Everything in +# `standard` is here unchanged; what is added is the self-referential Cordis +# toolset, a skill that teaches composition authoring, and a persona that says +# which of the two planes an edit belongs to. +# +# TRUST: `cordis_mount` evaluates model-written JavaScript against the live +# runtime, and a composition this agent writes becomes a preset other sessions +# mount. Treat a session on this preset as shell access — the toolset's own +# documentation makes the same statement. + + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are a coding agent powered by the {{model}} model, running on the DeepSeek Harness. Your working directory is {{cwd}}. + + You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session. + + Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it. + + Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. + + Load the `editing-cordis-compositions` skill before writing or changing a composition. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── goals ─────────────────────────────────────────────────────────────────── + +# Only the model-facing tool. The goal SERVICE, its session driver, and the +# `/goal` command stay on the host plane: the Gateway serves the goal domain as +# Remote endpoints whose receiver comes from a generated descriptor, so it +# resolves `goals` on the host and an entry-local realm here would hide it. The +# registry is keyed by session anyway, so one host instance serves every +# session. What a preset chooses is whether its agent can call the goal tool. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + toolResultPrune: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +# +# `tool-subagent-report` is host-plane for the same reason as the registry, +# not because a preset may not want it: it registers a CONTINUABLE SETUP on +# that singleton rather than a tool this agent calls, and the setup list is +# not scope-aware — one copy per mounted preset means every child gets +# `report` registered once per live session, which throws on the second. +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + +# ── self-modification ─────────────────────────────────────────────────────── + +# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a +# trust boundary, not a sandbox — see this file's header. +- id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + +# The composition-authoring skill travels with this preset rather than living +# in the user's skill root: it documents THIS deployment's two planes, and a +# preset is the unit that gets copied and edited. `baseUrl` is the preset's +# own directory, so the root resolves wherever the preset is installed. +# Both rows register into THIS preset's layer of the host skill registry, so +# they need no realm; the agent's merged catalog also carries whatever the +# deployment registered globally (repository plugins). +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + config: + customSkillDirs: + - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))" + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml new file mode 100644 index 0000000000..49cb3c6d44 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -0,0 +1,3 @@ +name: 创造模式 +description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 +order: 4 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md new file mode 100644 index 0000000000..3810ec334b --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -0,0 +1,68 @@ +--- +name: editing-cordis-compositions +description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing. +--- + +# Editing Cordis compositions + +Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. + +## Decide the plane first + +Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared. + +**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. + +**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. + +**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. + +A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<name>/`. + +## Authoring a preset + +1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. +2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. +3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. +4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. + +The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. + +## The rule that catches people + +**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. + +Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service. + +When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm: + +```yaml +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' +``` + +`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate. + +A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing. + +Registry-shaped host capabilities need no realm at all: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally. + +## Verifying a change + +Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it. + +To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. + +`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. + +## What not to move into a preset + +`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml new file mode 100644 index 0000000000..8ca6f0dcdf --- /dev/null +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -0,0 +1,31 @@ +# The `minimal` agent preset: the two-tool benchmark surface. +# +# The native model surface is exactly persistent `bash` plus +# `str_replace_editor`. Everything else a session could reach — skills, goals, +# plan mode, delegation, workflows, todo, web — is simply absent rather than +# disabled, because a preset composes what an agent has instead of subtracting +# from a shared default. +# +# The host composition is unchanged: this agent still runs inside the same +# sandbox, approval, persistence, and model routing as any other session. + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml new file mode 100644 index 0000000000..5521dda140 --- /dev/null +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -0,0 +1,3 @@ +name: 极简模式 +description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +order: 3 diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml new file mode 100644 index 0000000000..66407faf1d --- /dev/null +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -0,0 +1,229 @@ +# The `standard` agent preset: the full coding agent, mounted once per process. +# +# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a +# standing scope; every session naming it joins by scope parentage, so the +# tools and prompt sections registered here cover each joined agent while a +# session's own state stays keyed per Session/Agent inside the plugins. The +# host composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a +# preset must not own: the registries themselves, the sandbox and approval +# stack, persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global — +# another preset publishing the same name collides, and a host reader would +# resolve one preset's instance for every session; `dsh-agent-presets` rejects +# that at mount. `true` means an entry-local realm: this standing mount's own +# private instance, apart from every other preset's. (A shared label does NOT +# pool instances — `provide()` throws on the second registration under the +# same realm symbol; labels join REALMS, and are not what this file needs.) + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── skills ────────────────────────────────────────────────────────────────── + +# The skill REGISTRY lives in the host composition and is layered per scope: +# these rows register into THIS preset's layer of it, so they need no realm. +# `skill-local` contributes local-root discovery for agents on this preset, and +# `tool-skill` gives them the catalog and loader; the merged catalog also +# carries whatever the deployment registered globally (repository plugins). +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +# Only the model-facing tool. The goal SERVICE, its session driver, and the +# `/goal` command stay on the host plane: the Gateway serves the goal domain as +# Remote endpoints whose receiver comes from a generated descriptor, so it +# resolves `goals` on the host and an entry-local realm here would hide it. The +# registry is keyed by session anyway, so one host instance serves every +# session. What a preset chooses is whether its agent can call the goal tool. +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + toolResultPrune: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. +# +# `tool-subagent-report` is host-plane for the same reason as the registry, +# not because a preset may not want it: it registers a CONTINUABLE SETUP on +# that singleton rather than a tool this agent calls, and the setup list is +# not scope-aware — one copy per mounted preset means every child gets +# `report` registered once per live session, which throws on the second. +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 diff --git a/apps/cli/config/agent-presets/standard/preset.yml b/apps/cli/config/agent-presets/standard/preset.yml new file mode 100644 index 0000000000..8eddfbde48 --- /dev/null +++ b/apps/cli/config/agent-presets/standard/preset.yml @@ -0,0 +1,3 @@ +name: 标准模式 +description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 +order: 1 diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 0d6960c2a3..43860418c4 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -75,8 +75,11 @@ - id: tool-str-replace-editor disabled: true -# The matching browser controls must not offer host tools that this profile -# omits. ui-question's host half owns the ask_user_question registration. +# The matching browser controls must not offer surfaces whose tool this +# overlay omits: the panels would render for a capability the model does not +# have. Turning the row off no longer removes a tool — `ui-question`'s host +# half is empty and `tool-ask-user` is composed per preset — so this is a UI +# decision now, not a capability one. - id: ui-plan disabled: true diff --git a/apps/cli/package.json b/apps/cli/package.json index a648901cb9..d312dd28d9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,19 +17,51 @@ "@cordisjs/plugin-include": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/dsh-agent-tool-mode": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0", @@ -44,6 +76,7 @@ "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 1754eb4efd..d06bddbc6f 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -15,6 +15,7 @@ import { type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -33,6 +34,12 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re label: layer.packageName, patches: layer.patches, })) + // The win32 shell platform layer rides between bundles and user layers, + // exactly where the boot applies it. + const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME) + if (windowsShellLayer !== undefined) { + layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches }) + } if (!defaultOnly) { if (existsSync(loaded.patchPath)) { layers.push({ label: loaded.patchPath, patches: loaded.patches }) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index b4fee306cf..e4a719379e 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -12,6 +12,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' +import { dshHomePath } from '@deepseek-ai/dsh-paths' import { boot, composeEntries, @@ -25,9 +26,16 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' + +/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ +const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) + +/** Harness-home directory holding locally authored agent presets. */ +const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -104,6 +112,8 @@ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ bundlePatches: PatchOptions[] + /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */ + windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ @@ -118,12 +128,19 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags] + return [ + ...composed.bundlePatches, + ...composed.windowsShellPatches, + ...composed.profile.patches, + ...composed.homePatches, + ...composed.overlayAndFlags, + ] } /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order, the profile's user layer, the home-level user layer + * `dsh.profile.bundles` order, the win32 shell platform layer (when the host + * is Windows), the profile's user layer, the home-level user layer * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to * every profile, so it outranks the per-profile layer), `--patch` overlays, * then flag patches derived from the composed rows, then the telemetry @@ -142,14 +159,33 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) + const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] const rows = new Map<string, { name?: string; config?: unknown }>() - for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { + for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] + // The agent-preset roots are an assembly fact of every dsh launcher, not a + // patch author's choice: the shipped set sits beside this app's config and + // the user's own under the Harness home. Resolved per boot ($DSH_HOME may + // differ per run) and only patched when the composed tree actually mounts + // the roster — a one-shot `dsh run` composes agents from the same roster + // `dsh web` offers. + if (rows.has('agent-presets')) { + overlayAndFlags.push({ + id: 'agent-presets', + config: { + ...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>, + roots: [ + { path: SHIPPED_PRESET_ROOT, trust: 'system' }, + { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }, + ], + }, + }) + } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, homePatches, overlayAndFlags, rows } + return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } } /** Options for {@link runProfile}. */ @@ -207,7 +243,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted front door can publish readiness before sibling rows + // settles: an inserted entry point can publish readiness before sibling rows // finish mounting. process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) process.on('SIGINT', () => { interrupt(130) }) @@ -228,6 +264,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // removing the override could never revert the row to the bundle default. const composeLive = (): PatchOptions[] => structuredClone([ ...composed.bundlePatches, + ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlayAndFlags, diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 72abee67d0..bdf301e2ae 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -97,6 +97,9 @@ function deriveWebFlagPatches( // inserts the client-hmr row), never pass-throughs of composed values. put('web-runtime', 'mode', flags.dev ? 'development' : 'production') put('web-runtime', 'lanAddresses', lanAddresses) + // The agent-preset roots are patched by the shared profile boot: they are + // an assembly fact of every dsh launcher, and `dsh run` composes agents + // from the same roster this alias offers. const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { const composed = rows.get(id) if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts new file mode 100644 index 0000000000..fbb3d13194 --- /dev/null +++ b/apps/cli/src/windows-shell.ts @@ -0,0 +1,52 @@ +/** + * The Windows shell platform layer: on win32 hosts the shipped profile + * compositions swap the POSIX-only bash stack for the sandbox-confined + * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` + + * `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's + * `windows.cordis.patch.yml`, injected by the launcher between the bundle + * layers and the user layers so a user patch can still override it — the + * only override channel is composition config, like every other roster + * decision. POSIX hosts never receive the layer. + * @module @deepseek-ai/dsh/windows-shell + */ + +import { join } from 'node:path' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' + +/** The base bundle whose package carries the Windows shell patch. */ +export const BASE_BUNDLE = '@deepseek-ai/dsh-base' + +/** The Windows shell patch filename inside the base bundle package. */ +export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml' + +/** One Windows shell platform layer: its patch file and parsed patches. */ +export interface WindowsShellLayer { + /** The patch file path, used as the config-dump provenance label. */ + label: string + /** The parsed patch entries, applied after the bundle layers. */ + patches: PatchOptions[] +} + +/** + * Resolve the Windows shell platform layer for a profile composition. + * @param platform - the host platform (`process.platform` at call sites). + * @param layers - the profile's bundle layers, in application order. + * @param binName - the diagnostic prefix on thrown errors (`dsh`). + * @returns the pwsh layer on win32, else `undefined`. A custom profile that + * mounts no base bundle is skipped (it owns its shell stack); a base + * bundle whose Windows shell patch is missing fails loud in + * {@link loadOverlayPatches} — the shipped package always carries it, so + * a miss is a broken installation. + */ +export function resolveWindowsShellLayer( + platform: NodeJS.Platform, + layers: readonly ProfileLayer[], + binName: string, +): WindowsShellLayer | undefined { + if (platform !== 'win32') return undefined + const base = layers.find(layer => layer.packageName === BASE_BUNDLE) + if (base === undefined) return undefined + const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) + return { label, patches: loadOverlayPatches(binName, label) } +} diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts new file mode 100644 index 0000000000..1bfaed8c67 --- /dev/null +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -0,0 +1,547 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { Context } from 'cordis' +import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { beforeAll, describe, expect, it } from 'vitest' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { CallId } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-skill' +import type {} from '@deepseek-ai/dsh-tools' + +const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ +const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') +const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') +/** The installation anchor whose dependency surface the preset module fallback mirrors. */ +const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') + +/** + * Boot the shipped Web composition, minus the rows that would bind a port, + * touch the network, or write outside the test. Everything that decides an + * agent's capabilities is the real thing, including both shipped presets. + */ +async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise<Context> { + const storageRoot = join(dirname(settingsFile), 'storages') + const patches: PatchOptions[] = [ + ...loadOverlayPatches('dsh-test', BASE_PATCH), + ...loadOverlayPatches('dsh-test', WEB_PATCH), + // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it + // reads the developer's own document — and since the default preset is a + // setting, a stored `agent-presets.default` would decide this file's + // outcome. Point it at a temp file for the same reason the roster below + // names only the shipped root. + { id: 'settings', config: { path: settingsFile, watch: false } }, + // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this + // file writes the developer's own `~/.dsh/storages/` — and then reads it + // back on the next run, so a stored document from any other build decides + // this test's boot. Same reason the settings row above is pinned. + { id: 'storage-json', config: { root: storageRoot } }, + // Host rows with side effects outside this process: a bound port, a served + // asset tree, a telemetry exporter. `api-gateway` and `directory-picker` + // stay ENABLED on purpose — the api-proxy is the host row that injects + // `subagents`, `workspace`, and the rest of the agent plane, so disabling + // it would hide exactly the breakage this file exists to catch: a service + // moved into the presets that a host row still waits for. The boot audit + // is that assertion. + { id: 'webserver', disabled: true }, + // The web bundle's runtime row injects `httpServer`, so it cannot + // activate without the bound port disabled above. It owns dist serving + // and the URL prompt line — surface glue, not anything that decides an + // agent's capabilities, which is all this file asserts. + { id: 'web-runtime', disabled: true }, + { id: 'telemetry-otel', disabled: true }, + // A deployment-level skill on the host registry's GLOBAL layer — the same + // registration shape a repository plugin's skill root uses. The layered + // skills test below proves it reaches preset-composed agents. + { id: 'skill-badge', disabled: false }, + { id: 'modules', disabled: true }, + { id: 'connection', disabled: true }, + // The shipped `-auto` chooser resolves its interaction from a running + // host and so waits for the webserver disabled above; the browse variant + // supplies `directoryPicker` without one. + { id: 'directory-picker', disabled: true }, + { insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] }, + // The roster AppCLIEntry would patch in; only the shipped root, so a + // developer's own `~/.dsh/.preset` cannot change this test's outcome. + // `default` here is the COMPOSITION default — the base layer the settings + // document overrides. + { + id: 'agent-presets', + config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, + }, + ...extra, + ] + // The surface is patch layers over an empty preset root, so the root sits + // outside this workspace and bare plugin names cannot resolve by Node's + // upward walk. The flat fallback the preset boot maintains is what makes + // them resolvable — the same mechanism, not a test-only shim. + const home = dirname(settingsFile) + healProfilesModuleFallback(INSTALL_ANCHOR, home) + const profileDir = join(home, 'profiles', 'spec') + await mkdir(profileDir, { recursive: true }) + const rootConfig = join(profileDir, 'cordis.yml') + await writeFile(rootConfig, '[]\n') + return await boot('dsh-test', rootConfig, patches) +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +let ctx: Context +beforeAll(async () => { + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + ctx = await bootWeb(settingsFile) +}, 120_000) + +describe('the shipped Web composition', () => { + it('leaves the global tool layer empty', () => { + // Every model-facing tool belongs to a preset, `ask_user_question` + // included: a tool in the global layer reaches EVERY agent regardless of + // which preset composed it, so a two-tool benchmark surface would really + // present three. A regression here means an agent-plane row came back to + // the host composition. + expect(toolNames(ctx)).toEqual([]) + }) + + it('supplies both shipped presets, and only those, from the system root', async () => { + const listed = await ctx.agentPresets.list() + + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('composes the full agent from `standard`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-standard'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The EXACT catalog, not a spot-check: an omission is this design's + // quietest failure mode, because a row that registers into the wrong + // layer mounts cleanly and simply contributes nothing. `glob`/`grep` are + // excluded for the reason the TUI composition e2e excludes them — they + // depend on ripgrep being present on the machine. + expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ + 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', + 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill', + 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', + 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', + 'workflow', 'write', + ]) + } finally { + await handle.dispose() + } + }) + + it('composes exactly two tools from `minimal`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + // Exactly what the preset lists — nothing arrives from the host. + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('keeps two differently composed sessions independent', async () => { + const full = await ctx.agents.create({ + sessionId: SessionId('preset-both-full'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + const minimal = await ctx.agents.create({ + sessionId: SessionId('preset-both-minimal'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + + await minimal.dispose() + + // Tearing the minimal session down leaves the full one whole. + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + expect(toolNames(ctx)).toEqual([]) + } finally { + await full.dispose() + } + }) + + it('composes the cordis agent with its own toolset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined), + }) + try { + const tools = toolNames(ctx, handle.agent) + // The self-referential toolset is what distinguishes this preset. + expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) + // And it keeps the standard agent's own tools rather than replacing them. + expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) + + // The preset's own authoring skill registers into ITS layer of the host + // registry: the cordis agent's view carries it, the global view does not. + const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) + expect(scoped).toContain('editing-cordis-compositions') + expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions') + } finally { + await handle.dispose() + } + }) + + it('presents `code` as Code Mode without disturbing a native session beside it', async () => { + const coded = await ctx.agents.create({ + sessionId: SessionId('preset-code'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined), + }) + const native = await ctx.agents.create({ + sessionId: SessionId('preset-code-native'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // One tool reaches the MODEL: the transport. The registry's catalog for + // this agent is unchanged — a code mode collapses the presentation, not + // the capabilities — so the assembly is what carries the claim. + const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) + expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor') + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' + expect(sdk).toContain('str_replace_editor') + expect(sdk).toContain('web_search') + + // The presentation is this agent's alone: the deployment default is + // native, and the session composed from `standard` still sees it. + const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent }) + expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash') + expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code') + expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + } finally { + await native.dispose() + await coded.dispose() + } + }) + + it('keeps the self-referential toolset out of every other preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-no-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // Editing the live runtime is opt-in per session, not ambient. + expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount') + } finally { + await handle.dispose() + } + }) + + it('ships the composition-authoring skill inside the preset directory', async () => { + // The preset's skill root is derived from its own `baseUrl`, so the skill + // travels with the directory wherever the preset is installed. + const skill = join( + CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + ) + + expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) + }) + + it('merges the global skill layer into a preset agent\'s catalog, keeping local discovery preset-side', async () => { + const proj = await mkdtemp(join(tmpdir(), 'dsh-preset-skill-proj-')) + await mkdir(join(proj, '.dsh', 'skills', 'project-proof'), { recursive: true }) + await writeFile(join(proj, '.dsh', 'skills', 'project-proof', 'SKILL.md'), [ + '---', + 'name: project-proof', + 'description: Proves the preset layer discovers project skills beside global ones.', + '---', + '', + 'Project proof body.', + '', + ].join('\n')) + + const handle = await ctx.agents.create({ + // Unique per run: the composition persists into the ambient DSH home, + // and a fixed id would collide with a log an earlier run left there. + sessionId: SessionId(`preset-skills-standard-${randomUUID()}`), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The host (global) view carries the deployment-level provider alone: + // local discovery moved behind the presets with `skill-local`. + expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge']) + + // The standard agent's view merges the global layer with its preset's + // own local discovery over the session cwd. + const scoped = (await ctx.skills.list({ cwd: proj, scope: handle.agent })).map(skill => skill.name) + expect(scoped).toContain('dsh-badge') + expect(scoped).toContain('project-proof') + + // The preset's own loader tool resolves the global-layer skill. + const loaded = await ctx.tools.execute({ + callId: CallId('preset-skills-load'), + name: 'skill', + arguments: { name: 'dsh-badge' }, + signal: new AbortController().signal, + agent: handle.agent, + }) + expect(loaded.isError).toBe(false) + expect(JSON.stringify(loaded.content)).toContain('powered by dsh') + } finally { + await handle.dispose() + } + }) + + it('shows a minimal agent the global layer but no loader tool', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId(`preset-skills-minimal-${randomUUID()}`), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + // Layer visibility is the registry's; whether an agent can USE skills + // stays the preset's choice — minimal mounts no `tool-skill`, so its + // tool table has no loader even though the global layer is readable. + expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('never rewrites the preset file it composed from', async () => { + // The Loader persists a tree whose plugin self-disposed, and tearing an + // agent down disposes its whole subtree. Inherited, that rewrote the + // shipped composition — truncating it to `[]` the first time a session + // ended — so `PresetTree` refuses to write at all. + const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml') + const before = await readFile(path, 'utf8') + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-readonly'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await handle.dispose() + // Slack, not a race the number has to win. The write is driven by the + // Loader's fiber-unload listener, which fires as the subtree's fibers + // settle rather than when `dispose()` resolves, and the Loader exposes no + // flush to await. A regression writes synchronously inside that listener, + // so any wait past settlement fails; a longer one only slows the test. + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(await readFile(path, 'utf8')).toBe(before) + }) + + it('gives each session its own persona', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-persona'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) + .toContain('You are a coding agent powered by') + } finally { + await handle.dispose() + } + }) +}) + +describe('a switch survives the session', () => { + it('records the choice so the log states what the agent runs', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-switch-logged'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The api-proxy's select does exactly this pair while the session is blank. + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' }) + + // The header keeps the creation fact; the log carries what it runs. + expect(handle.agent.session.header.agentPreset).toBe('standard') + expect(resolveSessionPreset(handle.agent.session)).toBe('minimal') + } finally { + await handle.dispose() + } + }) + + it('rebuilds a switched session from the log, not the creation header', () => { + // The exact shape a resume reads back from disk: the header says standard, + // the log records the switch the user made while the session was blank. + const rebuilt = resolveSessionPreset({ + header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' }, + events: [ + { type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }, + { type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] as never, + }) + + // Reading the header alone would compose the creation-time preset over a + // history another one produced — the replay the blank-only lock prevents. + expect(rebuilt).toBe('minimal') + }) +}) + +describe('a forked session', () => { + it('inherits the composition its seeded history was produced under', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-fork-parent'), + meta: { agentPreset: 'minimal' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + const inherited = resolveSessionPreset(parent.agent.session) + const child = await ctx.agents.create({ + sessionId: SessionId('preset-fork-child'), + meta: { + parentSession: SessionId('preset-fork-parent'), + seedLength: 0, + ...inherited === undefined ? {} : { agentPreset: inherited }, + }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined), + }) + try { + // Composing nothing would leave the child empty: this layer moved every + // model-facing row out of the host plane, so there is nothing to inherit + // for free any more. + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0) + } finally { + await child.dispose() + await parent.dispose() + } + }) +}) + +describe('authoring a preset on the shipped composition', () => { + let authorCtx: Context + let userRoot: string + + beforeAll(async () => { + userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles') + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + authorCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + // The root does not exist yet: a deployment whose user has authored + // nothing is the normal first-run state. + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }) + + it('refuses to copy over or delete a shipped preset', async () => { + await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/) + await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/) + }) + + it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => { + // The id becomes a directory name under the user root, so containment is + // checked on the id rather than on the joined path afterwards. + await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow() + }) + + it('copies a shipped preset a session then really composes from', async () => { + await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式') + + // Round-trips through the roster as a `user` row carrying the given name + // and the source's description, over the source's own composition text. + const preset = await authorCtx.agentPresets.resolve('my-agent') + const source = await authorCtx.agentPresets.resolve('minimal') + expect(preset.trust).toBe('user') + expect(preset.name).toBe('我的模式') + expect(preset.description).toBe(source.description) + expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal')) + // Owner-only, in an owner-only directory: a composition is executable + // configuration on a machine that may have other users. + expect((await stat(preset.path)).mode & 0o777).toBe(0o600) + const handle = await authorCtx.agents.create({ + sessionId: SessionId('preset-authored'), + setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), + }) + try { + // The same tools the shipped `minimal` composes, from a directory copied + // through the service into a root outside the installed harness. + expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('deletes what it copied', async () => { + await authorCtx.agentPresets.copy('minimal', 'doomed') + + await authorCtx.agentPresets.remove('doomed') + + expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed') + }) +}) + +/** + * Which preset an unnamed session gets is a user setting layered over the + * composition's own default. The package suite proves the layering against a + * hand-built context; this proves it through the shipped `cordis.yml` — that + * the roster and the settings provider are actually wired to each other, and + * that the id the setting names is the one a session composes from. + */ +describe('the default preset as a user setting', () => { + it('composes an unnamed session from the stored default, not the composed one', async () => { + expect(ctx.agentPresets.defaultId).toBe('standard') + + await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'minimal' }) + try { + expect(ctx.agentPresets.defaultId).toBe('minimal') + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-user-default'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + // `mount()` with no id resolves the effective default. Two tools, not + // `standard`'s catalog: the setting decided the composition. + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + } finally { + // The context is shared with the rest of the file. `replace({})` drops + // the user section wholesale so the field re-inherits the composition + // base; `update` merges, and would leave the override standing. + await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {}) + } + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) + +describe('a session keeps the preset it was created with', () => { + it('refuses to adopt a live session under a different preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-locked'), + meta: { agentPreset: 'minimal' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + // The api-proxy guard reads exactly this: the header records what the + // session runs, so naming anything else is a caller error rather than a + // switch. Its history was produced under `minimal`'s two tools. + expect(handle.agent.session.header.agentPreset).toBe('minimal') + } finally { + await handle.dispose() + } + }) +}) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts new file mode 100644 index 0000000000..569ba91b34 --- /dev/null +++ b/apps/cli/tests/windows-shell.spec.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' +import { + BASE_BUNDLE, + resolveWindowsShellLayer, + WINDOWS_SHELL_PATCH_FILENAME, +} from '../src/windows-shell.ts' + +const WINDOWS_PATCH = `- id: bash-sandbox + disabled: true +- insert: + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' +` + +/** One fake bundle layer rooted in a temp directory. */ +function fakeLayer(packageName: string, dir: string): ProfileLayer { + return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] } +} + +/** A base bundle layer whose package carries the Windows shell patch. */ +function baseLayerWithPatch(dir: string): ProfileLayer { + writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH) + return fakeLayer(BASE_BUNDLE, dir) +} + +describe('resolveWindowsShellLayer', () => { + let base: string + afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) }) + const tempBase = (): string => { + base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-')) + return base + } + + it('never applies on POSIX hosts', () => { + expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + }) + + it('defaults Windows hosts to the pwsh platform layer', () => { + const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh') + expect(layer).toBeDefined() + expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) + expect(layer?.patches).toEqual([ + { id: 'bash-sandbox', disabled: true }, + { insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] }, + ]) + }) + + it('skips custom profiles without a base bundle', () => { + const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase()) + expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined() + }) + + it('fails loud when the base bundle ships no Windows shell patch', () => { + const base = tempBase() + mkdirSync(base, { recursive: true }) + // The overlay loader owns the fail-loud contract: the caller named this + // file, so its absence is a misconfiguration, not "no overlay". + expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) + .toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/) + }) +}) + +describe('the shipped Windows composition (real bundle layers)', () => { + let home: string + afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) }) + // The app installation anchor, mirroring profile-boot.ts: the bundle layers + // resolve from the REAL dsh-base/dsh-web-app packages through it, so this + // suite composes the shipped patch files, not test fixtures. + const anchor = fileURLToPath(new URL('../package.json', import.meta.url)) + + it('composes the win32 confined roster through the real patch layers', () => { + home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) + initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const profile = loadProfile('dsh', 'web', anchor, home) + const warnings: string[] = [] + const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh') + expect(win32).toBeDefined() + const rows = composeEntries( + [...profile.layers.map(layer => layer.patches), win32!.patches], + message => warnings.push(message), + ) + const byId = new Map(rows.map(row => [row.id, row])) + // Only the POSIX bash stack leaves the roster: the permission surface + // (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled + // exactly as on POSIX — the confined pwsh executor is what changes. + for (const id of ['bash-sandbox', 'tool-bash']) { + expect(byId.get(id)?.disabled, `row ${id}`).toBe(true) + } + for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { + expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true) + } + for (const id of ['pwsh-sandbox', 'tool-pwsh']) { + expect(byId.has(id), `inserted row ${id}`).toBe(true) + } + // The launcher's cold-start module fallback BFS-links the apps/cli + // dependency closure into the profile's node_modules (the pwsh-local + // precedent), so every inserted bare plugin must resolve from there. + const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> } + for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) { + expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined() + } + // The patch touches only base-owned rows plus inserts, so the full web + // profile composes without any no-match warning. + expect(warnings).toEqual([]) + }) + + it('leaves POSIX untouched and base-only profiles compose without warnings', () => { + home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) + initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const profile = loadProfile('dsh', 'web', anchor, home) + // POSIX: no platform layer, the bash stack stays enabled. + const posixRows = composeEntries(profile.layers.map(layer => layer.patches)) + const posixById = new Map(posixRows.map(row => [row.id, row])) + expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true) + expect(posixById.has('pwsh-local')).toBe(false) + expect(posixById.has('pwsh-sandbox')).toBe(false) + + // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the + // patch touches only base-owned rows (bash-sandbox/tool-bash) plus its + // inserts, so the composition produces no no-match warning. + initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base']) + const baseOnly = loadProfile('dsh', 'base-only', anchor, home) + const baseWarnings: string[] = [] + const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh') + expect(win32).toBeDefined() + composeEntries( + [...baseOnly.layers.map(layer => layer.patches), win32!.patches], + message => baseWarnings.push(message), + ) + expect(baseWarnings).toEqual([]) + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 10c2dc4702..c58e5b9682 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@cordisjs/plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts new file mode 100644 index 0000000000..53b0a406ce --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -0,0 +1,272 @@ +// Web e2e scenario: the agent-preset settings section as copy-only authoring. +// The browser never edits composition text — a shipped preset opens in a +// read-only viewer, the copy dialog collects an id and an optional display +// name, and the host copies the whole directory. The section's other job is +// getting the user TO the files: this lane pins `nativeOpen: false` (see the +// overlay), so the location affordance answers the preset directory as text — +// the deterministic branch a golden can hold on every platform. +// +// Zero model calls: no replay fixture mounts, so a stray stream fails loud. +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Locator } from 'playwright' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url)) +const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') +const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') +const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') +const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md') +/** The shipped roster, beside the composition that names it. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: agent-preset authoring is a host-side copy', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + let userRoot: string + + /** The settings dialog, opened on the Agent-presets section. */ + function settingsDialog(): Locator { + return page.getByRole('dialog', { name: '设置' }) + } + + /** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */ + function withPresetRoot(snapshot: string): string { + return snapshot.split(userRoot).join('{{presetRoot}}') + } + + beforeAll(async () => { + userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) + scaffold = await launchWebScaffold({ + extraOverlayPath: OVERLAY, + agentPresets: { + roots: [ + { path: SHIPPED_PRESETS, trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + default: 'standard', + }, + }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers the roster with copy as the only way to create', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = settingsDialog() + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) + await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) + // The intro carries the guidance a create button used to imply, and the + // shipped rows offer view/copy but never delete or a location — their + // install is overwritten by upgrades and is not the user's to manage. + expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建') + expect(snapshot).not.toContain('新建预设') + expect(snapshot).toContain('查看: 标准模式') + expect(snapshot).not.toContain('删除: 标准模式') + expect(snapshot).not.toContain('打开目录') + }, 60_000) + + it('views a shipped composition read-only instead of editing it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '查看: 标准模式' }).click() + const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' }) + await viewer.waitFor({ timeout: 10_000 }) + + // The real shipped composition, not a golden: the viewer shows whatever + // the deployment ships, and this lane only asserts it is shown read-only. + const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8') + expect(await viewer.locator('pre').textContent()).toBe(shipped) + expect(await viewer.getByRole('textbox').count()).toBe(0) + // The header X and the footer button share the 关闭 name; the footer one + // is last in the dialog. + await viewer.getByRole('button', { name: '关闭' }).last().click() + await viewer.waitFor({ state: 'detached', timeout: 10_000 }) + }, 60_000) + + it('copies 极简模式 whole under a new id and lands in its files', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) + await copyDialog.waitFor({ timeout: 10_000 }) + + const dialogSnapshot = await captureStableAria( + page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE) + // Two fields and nothing else: the id is the directory name the host + // needs up front; description and composition live in the files. + expect(dialogSnapshot).toContain('标识符') + expect(dialogSnapshot).not.toContain('描述') + + await copyDialog.getByPlaceholder('my-agent').fill('my-agent') + await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式') + await copyDialog.getByRole('button', { name: '创建' }).click() + await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) + + // The new row lands in the custom group, and — with no desktop opener — + // its directory is revealed as text right away: landing in the files is + // the completion of a copy, not a follow-up. + await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 }) + await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 }) + // The copy dialog is detached, so the settings dialog is the only one + // left (it names itself via aria-labelledby, which a CSS attribute + // selector cannot address). + const snapshot = withPresetRoot( + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('{{presetRoot}}/my-agent') + + // The host copied the whole directory and rewrote only the display + // metadata: the composition is byte-identical to the shipped source, the + // description rides along for the user to edit in place, and neither the + // source's name nor its roster order survives into the copy. + const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8') + expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) + const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') + expect(metadata).toContain('name: 我的模式') + expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。') + expect(metadata).not.toContain('order:') + }, 60_000) + + it('deletes the copy after confirmation and reclaims the roster', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '删除: 我的模式' }).click() + const confirm = page.getByRole('dialog', { name: '删除该预设?' }) + await confirm.waitFor({ timeout: 10_000 }) + await confirm.getByRole('button', { name: '删除', exact: true }).click() + await confirm.waitFor({ state: 'detached', timeout: 10_000 }) + + await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) + expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) + // Custom group gone with its only member; the shipped set stands. + expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0) + expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) + }, 60_000) + + it('marks damaged presets broken and clears a ghost through delete', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged')) + // The two hand-edit damage shapes: a composition that no longer parses, + // and a directory whose composition file was deleted outright. + await mkdir(join(userRoot, 'broken-yaml'), { recursive: true }) + await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n') + await mkdir(join(userRoot, 'ghost'), { recursive: true }) + await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n') + + // The section reads the roster when it mounts; hop away and back. + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '通用设置' }).click() + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 }) + + const snapshot = withPresetRoot( + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE) + // Both damage shapes surface as marked, unselectable, uncopyable cards + // that still carry their metadata and the discovery-reported reason. + expect(snapshot).toContain('已损坏: broken-yaml') + expect(snapshot).toContain('已损坏: 幽灵预设') + expect(snapshot).toContain('not valid YAML') + expect(snapshot).toContain('agent.cordis.yml is missing') + expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true) + // A broken card offers no "set default" affordance at all — the aria name + // IS the broken marking, so the picking name must not exist. + expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0) + + // The ghost's way out is the card's own delete — and the id it blocked + // is claimable again immediately afterwards. + await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click() + const confirm = page.getByRole('dialog', { name: '删除该预设?' }) + await confirm.waitFor({ timeout: 10_000 }) + await confirm.getByRole('button', { name: '删除', exact: true }).click() + await confirm.waitFor({ state: 'detached', timeout: 10_000 }) + await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0) + expect(existsSync(join(userRoot, 'ghost'))).toBe(false) + + await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) + await copyDialog.waitFor({ timeout: 10_000 }) + await copyDialog.getByPlaceholder('my-agent').fill('ghost') + await copyDialog.getByRole('button', { name: '创建' }).click() + await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) + await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 }) + + // Leave the roster as the earlier tests shaped it. + await dialog.getByRole('button', { name: '删除: ghost' }).click() + const cleanup = page.getByRole('dialog', { name: '删除该预设?' }) + await cleanup.waitFor({ timeout: 10_000 }) + await cleanup.getByRole('button', { name: '删除', exact: true }).click() + await cleanup.waitFor({ state: 'detached', timeout: 10_000 }) + await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true }) + }, 60_000) + + it('starts a creator-mode session from the section', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator')) + // Without a workspace the flow only stages (there is no session to land + // in until one is connected); connect first so the gesture carries all + // the way to a composed host session. + await settingsDialog().getByRole('button', { name: '关闭' }).last().click() + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = settingsDialog() + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click() + + // Leaving settings is part of the gesture: the flow lands on the + // new-session screen with the self-referential preset staged, and the + // blank session the flow produces composes from it on the host. + await dialog.waitFor({ state: 'detached', timeout: 10_000 }) + await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) + await expect.poll(async () => { + const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { sessions: unknown[] } } + } + return JSON.stringify(body.result.value?.sessions ?? body.result) + }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') + }, 60_000) + + it('drove every surface without a page error or a stream warning', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml new file mode 100644 index 0000000000..6644752bc2 --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -0,0 +1,12 @@ +# The authoring lane drives the location affordance. A real desktop open +# would pop a file manager on the machine running the tests and the +# capability itself is platform-detected (macOS yes, headless Linux CI no), +# so the gateway is pinned headless: `hasDocument` is false everywhere and +# `openDocument` answers the directory as text — the same branch on every +# host, and the one whose rendering a golden can hold. A patch replaces the +# row's complete config, so the shipped routing defaults ride along. +- id: api-gateway + config: + provider: deepseek-official + model: deepseek-v4-flash + nativeOpen: false diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts new file mode 100644 index 0000000000..69672f49e1 --- /dev/null +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -0,0 +1,153 @@ +// Web e2e scenario: agent-preset selection. The roster's `roots` is an +// assembly fact the CLI entry resolves and patches in, so every other lane +// boots with an empty roster and no preset surface at all; this is the one +// lane that mounts the SHIPPED presets and puts them in front of a browser. +// +// Two surfaces, one host rule: a session's composition is fixed when the +// session starts. Before that, the new-session chip stages the choice beside +// the workspace picker — the only screen where it still works. After it, the +// session header names what the session runs and offers no control at all, +// because the host answers `agent-preset-locked` to anything else. +// +// Zero model calls: no replay fixture mounts, so a stray stream fails loud. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', import.meta.url)) +const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') +const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') +/** The shipped roster, beside the composition that names it. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'agent-preset-selection-web-e2e' + +/** + * A settled one-turn session with no model content: this lane asserts chrome + * around a conversation, not a conversation, and a recorded turn would tie + * the golden to a provider's wording for no gain. + * @returns a tokenized session log ending on a closed turn. + */ +function seedLog(): string { + const time = 1784974100000 + const at = (index: number, event: Record<string, unknown>): string => + JSON.stringify({ ...event, seq: index, time: time + index }) + return [ + JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }), + at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }), + at(1, { + type: 'user/message', + data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } }, + surfaceOp: 'append', + }), + at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }), + at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }), + ].join('\n') +} + +describe('web e2e: agent-preset selection', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' }, + }) + // A resumed session runs what it was created with; seeding one that + // records `minimal` is what makes the header label a claim about the + // session rather than an echo of the current default. + await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers the chip on the new-session screen, beside the workspace picker', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero')) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + + const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) + // The chip opens on the deployment default, by the name that preset + // publishes rather than its directory name. + expect(snapshot).toContain('标准模式') + }) + + it('names every preset and what it is for', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu')) + await page.getByRole('button', { name: '标准模式' }).click() + const menu = page.getByRole('menu') + await menu.waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + // Every shipped preset, each with the sentence saying what it composes — + // the id alone never said what a preset does. + expect(snapshot).toContain('极简模式') + expect(snapshot).toContain('创造模式') + await page.keyboard.press('Escape') + }) + + it('applies the staged pick to the blank session, and the host honors it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage')) + await page.getByRole('button', { name: '标准模式' }).click() + await page.getByRole('menuitem', { name: /极简模式/ }).click() + + // The chip stages; the blank session the workspace connect produced is + // what the stage lands on. The host's own answer is what comes back. + await expect.poll(async () => { + const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } } + } + return JSON.stringify(body.result.value?.sessions ?? body.result) + }, { timeout: 15_000 }).toContain('minimal') + }) + + it('labels a resumed session with the preset it was created under', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header')) + // The seeded session's cwd is the scaffold root rather than the connected + // workspace, so it lists under Ungrouped; the group collapses by default. + await page.getByRole('treeitem', { name: /^Ungrouped/ }).click() + await page.locator('[role="treeitem"]').last().click() + await page.getByText('Seeded turn.').waitFor({ timeout: 15_000 }) + + const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('极简模式') + // Static chrome, not a control: the header can only report a composition + // the host would refuse to change. + expect(snapshot).not.toContain('button "极简模式"') + }) + + it('drove every surface without a page error or a stream warning', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index 6e14eebfa5..c504913b9b 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-skill' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' async function writeSkill(root: string, name: string): Promise<void> { @@ -37,10 +39,25 @@ it('isolates replay skill discovery from every ambient host root', async () => { let scaffold: WebScaffold | undefined try { scaffold = await launchWebScaffold() - const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) - expect(names).not.toContain('ambient-dsh') - expect(names).not.toContain('ambient-agents') - expect(names).not.toContain('ambient-bundled') + const ctx = scaffold.ctx + // Local skill discovery belongs to the agent's preset LAYER of the host + // registry, so the roots under test are only reachable through a composed + // agent's view — the same scope the gateway's `skill.list` resolves for a + // browser request about a session. + const handle = await ctx.agents.create({ + sessionId: SessionId('hermetic-skills'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const skills = ctx.get('skills') + if (skills === undefined) throw new Error('the composition mounts no skill registry') + const names = (await skills.list({ cwd: scaffold.workspaceCwd, scope: handle.agent })).map(skill => skill.name) + expect(names).not.toContain('ambient-dsh') + expect(names).not.toContain('ambient-agents') + expect(names).not.toContain('ambient-bundled') + } finally { + await handle.dispose() + } } finally { try { await scaffold?.close() diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 4a922b9353..0c5524fc42 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -32,6 +32,7 @@ import { expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import Group from '@cordisjs/plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { addHarnessSourceSection, @@ -85,6 +86,8 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the profile module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +/** The deployment's own agent-preset root, shipped beside the app's config. */ +const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -226,6 +229,20 @@ export interface LaunchOptions { /** Credential reference resolved by the shipped search provider. */ apiKeyEnv: string } + /** + * Replace the roster the scaffold mounts by default (the shipped directory + * at `system` trust, default `standard`). Supply this only to change WHICH + * presets a scenario sees — a writable user root, a different default — + * never to turn the roster on: without one every session composes an agent + * with no tools, no persona, and no token meter, which is not a shape the + * product ever boots in. The patch lands after the default, so it wins. + */ + agentPresets?: { + /** Roots to discover, in precedence order; the shipped directory is `system`. */ + roots: { path: string; trust: 'system' | 'user' }[] + /** The preset a session that names none is composed from. */ + default: string + } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean /** @@ -281,6 +298,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We // paths at load, and an in-process boot must NEVER touch the developer's // real ~/.dsh document or credential file. const harnessHome = join(workspaceCwd, '.dsh-home') + // Skill discovery is model-visible input, and its roots now resolve inside a + // PRESET — a subtree this lane's include patches cannot reach, because the + // roster mounts it directly per session rather than as a row of the booted + // tree. The row's documented fallback is the environment, so pin that: the + // whole scaffold lifetime, not just the boot, since presets mount when a + // session is created. Without this a developer's real ~/.dsh/skills silently + // enters replay requests and goldens while CI sees none. + const skillRootEnvironment = { + DSH_HOME: join(workspaceCwd, '.dsh-home'), + DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'), + DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'), + } + const originalSkillRootEnvironment = Object.fromEntries( + Object.keys(skillRootEnvironment).map(key => [key, process.env[key]]), + ) + let skillRootEnvironmentRestored = false + const restoreSkillRootEnvironment = (): void => { + if (skillRootEnvironmentRestored) return + skillRootEnvironmentRestored = true + for (const [key, value] of Object.entries(originalSkillRootEnvironment)) { + if (value === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = value + } + } + Object.assign(process.env, skillRootEnvironment) let persistenceRoot: string try { persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) @@ -310,6 +352,18 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We ...basePatches, ...surfacePatches, ...extraOverlayPatches, + // The roster's `roots` is an assembly fact AppCLIEntry resolves and patches + // in, exactly like `distIndex` on the webserver row — the shipped preset + // directory sits beside the composition that names it, and no config author + // chooses it. This lane boots the shipped tree WITHOUT AppCLIEntry, so it + // has to supply the same fact or the roster resolves nothing and every + // session composes an agent with no tools, no persona, and no token meter. + // Only the shipped root: a developer's own `~/.dsh/.agent-presets` must not be + // able to change a golden. + { + id: 'agent-presets', + config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] }, + }, { id: 'session-persistence-jsonl', config: { root: persistenceRoot } }, { id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } }, // storage-json's yml root is anchored to the real $DSH_HOME; pin the row @@ -360,6 +414,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We // disable+insert pair. { id: 'directory-picker', disabled: true }, { insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] }, + ...options.agentPresets === undefined + ? [] + : [{ id: 'agent-presets', config: options.agentPresets }], ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }], ...options.cordisTools === true ? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }] @@ -399,6 +456,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) ctx.loader.builtins.include = Include + // `cordis:group` beside it, exactly as `boot()` registers it: a group row is + // how a preset gives one `isolate` realm to a provider and its consumers, + // and a preset resolving package names from its own directory cannot reach + // `@cordisjs/plugin-group` by name. + ctx.loader.builtins.group = Group // The shipped CLI deliberately has no dependency on this opt-in package. // Keep the Loader row real without broadening the product installation. if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis @@ -449,6 +511,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We if (process.cwd() !== originalCwd) process.chdir(originalCwd) const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot) restoreCredentialEnvironment() + restoreSkillRootEnvironment() if (cleanupFailures.length > 0) { throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete') } @@ -496,6 +559,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)) } finally { restoreCredentialEnvironment() + restoreSkillRootEnvironment() } if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed') }, @@ -562,6 +626,8 @@ export function fixtureUserPrompts(fixtureText: string): string[] { * @param scaffold - the target scaffold. * @param fixtureText - raw recorded session.jsonl contents. * @param id - the seeded session id (stable for deterministic goldens). + * @param agentPreset - the preset the recorded session was composed from, + * for scenarios asserting what a resumed session reports running. * @returns the seeded id. */ /** @@ -585,7 +651,12 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i : realized.split(fixtureCwd).join(scaffold.workspaceCwd) } -export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> { +export async function seedSession( + scaffold: WebScaffold, + fixtureText: string, + id: string, + agentPreset?: string, +): Promise<SessionId> { const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id)) if (events.length === 0) throw new Error('seed fixture has no events') const last = events[events.length - 1]! @@ -598,6 +669,7 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id createdAt: Date.now() - 60_000, cwd: scaffold.workspaceCwd, delegationDepth: 0, + ...agentPreset === undefined ? {} : { agentPreset }, } const seeder = new Context() try { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 14de0e8d8f..d7080a5ed7 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -19,6 +19,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' @@ -195,10 +196,22 @@ describe('web e2e: seeded history renders through cold resume', () => { if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) - const meter = scaffold.ctx.get('tokenMeter') - if (meter === undefined) throw new Error('seeded-history requires the composed token meter') - const realized = realizeSeedFixture(scaffold, raw, SEED_ID) - await seedSession(scaffold, withCompaction(realized, meter), SEED_ID) + // The meter belongs to an agent's preset, not to the process — token + // accounting is per session. It is used here as a pure pricing function + // over fixture content, so a throwaway composition is enough to reach one. + const priced = await scaffold.ctx.agents.create({ + sessionId: SessionId('seeded-history-pricing'), + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + let realizedWithCompaction: string + try { + const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter') + if (meter === undefined) throw new Error('seeded-history requires the composed token meter') + realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) + } finally { + await priced.dispose() + } + await seedSession(scaffold, realizedWithCompaction, SEED_ID) } browser = await chromium.launch() page = await newEnglishPage(browser) @@ -245,10 +258,15 @@ describe('web e2e: seeded history renders through cold resume', () => { const projections = body.result.value?.projections expect(projections).toBeDefined() expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0) - // The seed carries a session/title event: the title unit must serve it. + // The seed carries a session/title event: the title unit is host-plane, so + // it folds the detached log and serves the value with nothing composed. expect(typeof projections?.values.title).toBe('string') - // tool-todo is composed but the seed has no todo/write: whole-value null, - // key PRESENT (absence would mean the unit never registered). + // `todos` IS here, as its empty fold (null). Its unit is registered by + // `tool-todo` inside the default preset's STANDING mount, which the read + // itself ensures — deterministically, not because some unrelated session + // happens to be composed. A present-but-null key is what keeps the + // client's "omitted key = capability absent → clear the row" rule from + // wiping preset-owned projections on cold reads. expect(projections?.values).toHaveProperty('todos', null) }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 11bbdcfd82..5d929044b3 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -12,6 +12,7 @@ import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-permission' +import type {} from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-commands' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' @@ -66,11 +67,26 @@ afterEach(async () => { it('assembles the shipped Web catalog with the confined access default', async () => { scaffold = await launchWebScaffold() - const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() - expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) - // The packaged ripgrep binary ships with the dependency, so the pair is a - // fixed roster member on every host. - expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) + const ctx = scaffold.ctx + // The catalog belongs to an AGENT, not to the process: every model-facing row + // now lives in a preset mounted under one session's scope, so the global + // layer holds nothing and a caller must name the agent to see anything. This + // composes from the deployment default — what a session that names no preset + // gets — which is the shape this test has always been about. + expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([]) + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-composition'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort() + expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) + // The packaged ripgrep binary ships with the dependency, so the pair is a + // fixed roster member on every host. + expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) + } finally { + await handle.dispose() + } // `workspace-write` is not "the workspace and nothing else": the shared roots // helper always admits the temp directories too. Pinning it against an // explicit mode keeps the claim independent of this surface's default, and @@ -83,18 +99,18 @@ it('assembles the shipped Web catalog with the confined access default', async ( expect(scaffold.ctx.approval.config.policy).toBe('ask') expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write') - const handle = await scaffold.ctx.agents.create({ + const commandHandle = await scaffold.ctx.agents.create({ sessionId: SessionId('shipped-command-catalog'), meta: { cwd: scaffold.workspaceCwd }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, }) try { - expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({ + expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({ name: 'feedback', description: 'record feedback about this session', input: { hint: '<text>' }, }) } finally { - await handle.dispose() + await commandHandle.dispose() } }, 120_000) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md new file mode 100644 index 0000000000..dc4045c5cc --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md @@ -0,0 +1,14 @@ +- dialog "复制预设 · 复制自 极简模式": + - heading "复制预设 · 复制自 极简模式" [level=2] + - button "关闭": + - img + - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 + - text: 标识符 + - textbox "标识符": + - /placeholder: my-agent + - text: 名称 + - textbox "名称": + - /placeholder: 选择器中显示的名字,缺省用标识符 + - alert: 请填写标识符。 + - button "取消" + - button "创建" [disabled] diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md new file mode 100644 index 0000000000..e5cefe28ef --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -0,0 +1,81 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - heading "自定义" [level=3] + - list: + - listitem: + - 'button "设为默认: 我的模式"': + - text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: my-agent + - 'button "查看路径: 我的模式"': + - img + - text: 查看路径 + - 'button "复制: 我的模式"': + - img + - text: 复制 + - 'button "删除: 我的模式"': + - img + - text: 删除 + - paragraph: + - text: 预设文件: + - code: {{presetRoot}}/my-agent + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md new file mode 100644 index 0000000000..8269dc2993 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -0,0 +1,93 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - heading "自定义" [level=3] + - list: + - listitem: + - 'button "已损坏: broken-yaml" [disabled]': + - text: broken-yaml 已损坏 自定义 暂无描述。 + - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" + - code: broken-yaml + - 'button "查看路径: broken-yaml"': + - img + - text: 查看路径 + - 'button "复制: broken-yaml" [disabled]': + - img + - text: 预设已损坏,无法复制 + - 'button "删除: broken-yaml"': + - img + - text: 删除 + - listitem: + - 'button "已损坏: 幽灵预设" [disabled]': + - text: 幽灵预设 已损坏 自定义 composition 已被手动删除。 + - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file + - code: ghost + - 'button "查看路径: 幽灵预设"': + - img + - text: 查看路径 + - 'button "复制: 幽灵预设" [disabled]': + - img + - text: 预设已损坏,无法复制 + - 'button "删除: 幽灵预设"': + - img + - text: 删除 + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md new file mode 100644 index 0000000000..ac5d6f6736 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -0,0 +1,63 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md new file mode 100644 index 0000000000..ef2ad4ef57 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -0,0 +1,4 @@ +- navigation "Session hierarchy": + - button "Seeded turn" [disabled] +- img +- text: 极简模式 diff --git a/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md new file mode 100644 index 0000000000..f2d54eb579 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md @@ -0,0 +1,8 @@ +- button "Choose workspace": + - img + - text: workspace + - img +- button "标准模式": + - img + - text: 标准模式 + - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md new file mode 100644 index 0000000000..fd92ab8b5a --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -0,0 +1,7 @@ +- menu: + - menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。": + - text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - img + - menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。" + - menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。" + - menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。" diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 99b6bac89b..f426539e87 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - 'button "Using ONE run_code program: run" [disabled]' + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 72d0a79756..2bc6f76a93 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 92183ee6ea..0529e000b7 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the bash tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index b1c0cb52ca..fa178e30d8 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "workspace" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index dfa23ca508..223006d59d 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -25,6 +25,10 @@ - img - text: workspace - img +- button "标准模式": + - img + - text: 标准模式 + - img - textbox "Describe what you want to build" - button "Commands": - img 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 4ccab18ac7..a234028a16 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -25,6 +25,10 @@ - img - text: workspace - img +- button "标准模式": + - img + - text: 标准模式 + - img - textbox "Describe what you want to build" - button "Commands": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index bf32465f2b..149d8ce3f9 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with the single word" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 01a8343313..1d87f01525 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index f75432e2e4..94d739baf1 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index 6c36405064..c461dd985a 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index a281ca26b2..70a9e69e95 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 036630c2d5..02746c7f76 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index df47e186c3..857bfaf13e 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index ab0a25b780..03169f8e72 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 931caf0acb..e50c347966 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c1cae54bb5..0ba0ebe1da 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - 'button "Plan a small change: add" [disabled]' + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index a524a02e23..32e3e4bc75 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 18b40d976a..d039c3d6d6 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -14,7 +16,7 @@ - paragraph: partial - status: Deep diving... - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 7dc4f38f86..453b83e20e 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -30,7 +32,7 @@ - tooltip "Save queued message" - button "Cancel editing": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 7370a15264..64451ab4ce 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "workspace" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -29,7 +31,7 @@ - button "Clear goal": - img - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index e1b1cf9084..a845590873 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 0d9ae5fcf3..386b3e9889 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -23,7 +25,7 @@ - img - button "Steer queued message": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index f358ff26f5..cf87f5acbd 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,10 +7,17 @@ - button "模型": - img - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 - button "打开配置文件" - button "关闭": - img - text: 关闭 + - text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的预设。 + - button "标准模式": + - text: 标准模式 + - img - text: 权限 选择新会话的默认权限模式 - button "Workspace Write": - text: Workspace Write diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index c77081584a..63bd2ef401 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "/user-invoke-demo and confirm the fixtur" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md new file mode 100644 index 0000000000..998ee98129 --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -0,0 +1,33 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- text: Running +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- status: Deep diving... +- text: "Interjection Interjection: include the word BANANA in your final reply." +- button "Copy": + - img +- text: "Interjection Interjection: include the word ORANGE in your final reply." +- button "Copy": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/steer-all/replay.override.json b/apps/web/tests/snapshots/steer-all/replay.override.json new file mode 100644 index 0000000000..6a1c133faf --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/replay.override.json @@ -0,0 +1,47 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "reasoning" }, + { "type": "reasoning-delta", "index": 0, "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { + "type": "tool-call-delta", + "index": 1, + "id": "call_00_steer_all", + "name": "ask_user_question", + "argumentsDelta": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." + } + }, + { + "type": "block-end", + "index": 1, + "block": { + "type": "tool-call", + "id": "call_00_steer_all", + "name": "ask_user_question", + "arguments": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + } + }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "Got it: BANANA and ORANGE." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "Got it: BANANA and ORANGE." } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md new file mode 100644 index 0000000000..a61f57572e --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -0,0 +1,43 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- button "Ask question 1/1 answered": + - img + - img + - text: Ask question 1/1 answered +- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- button "Copy": + - img +- paragraph: "Got it: BANANA and ORANGE." +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5f3f24f709..0100e28b9a 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d598613fa3..7479c3a8c0 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md index 0dd1189e3c..b1a32406de 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 828350b846..32ea9a9b1e 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 0281d242f4..b24385d48f 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use web_search to search exactly" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 6c96f9b6aa..8a09582b56 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -34,6 +34,18 @@ const REPLAY_PACE_MS = 100 const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.' const STEER = 'Interjection: include the word BANANA in your final reply.' +// Empty-draft flush scenario: an override-only fixture. The whole-script +// replacement answers both model calls of a FRESH session (no recorded +// session.jsonl exists — call 0 keeps the turn open with a question-tool +// call, call 1 is the reply after both steerings drain). +const STEER_ALL_DIR = fileURLToPath(new URL('./snapshots/steer-all', import.meta.url)) +const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl') +const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json') +const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md') +const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md') +const STEER_ONE = 'Interjection: include the word BANANA in your final reply.' +const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.' + /** Concatenated assistant text deltas — the model-visible reply body. */ function assistantText(events: SessionEvent[]): string { return events @@ -278,3 +290,102 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) }) + +describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + // The scenario boots a fresh session against the override-only fixture; + // the replay.override.json sidecar replaces the derived script, so the + // (deliberately absent) session.jsonl is never read. + scaffold = await launchWebScaffold({ + replayFixture: STEER_ALL_FIXTURE, + replayOverride: STEER_ALL_OVERRIDE, + paceMs: REPLAY_PACE_MS, + }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(30_000) + + // Call 0 streams a question-tool call; the fills must land inside the + // first replay window, before the question composer replaces the textarea. + await input.fill(PROMPT) + await input.press('Enter') + await input.fill(STEER_ONE) + await input.press('Enter') + await input.fill(STEER_TWO) + await input.press('Enter') + const dock = page.locator('[data-queue-dock]') + // Both messages queued: the two-row dock shows a collapsed count header, + // and Playwright text matching skips the hidden rows — expand the list, + // then assert each row's content. + await dock.getByText('2 queued messages').waitFor({ timeout: 10_000 }) + await dock.getByRole('button').click() + await dock.getByText(STEER_ONE, { exact: true }).waitFor({ timeout: 10_000 }) + await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + + // Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock + // empties, and the pending steering renders at the conversation tail. + await input.press('Meta+Enter') + await expect.poll( + () => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(), + { timeout: 10_000 }, + ).toBe(2) + expect(await page.locator('[data-queue-dock]').count()).toBe(0) + // The reasoning row streams independently of the steering handoff; wait + // for it so the mid snapshot pins the assistant step, not the pre-render + // gap a fast machine can catch between steering acceptance and the block. + await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 }) + const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE) + + // Answer the question; the step closes, the loop drains both steerings + // into one next-step request, and the final reply obeys both markers. + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: 30_000 }) + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + + const first = claimedMessages(sessionEvents, STEER_ONE) + const second = claimedMessages(sessionEvents, STEER_TWO) + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(assistantText(sessionEvents)).toContain('BANANA') + expect(assistantText(sessionEvents)).toContain('ORANGE') + await expect.poll(() => page.getByText(STEER_ONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText(STEER_TWO, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(STEER_ALL_DIR, [ + 'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md', + ]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 606f257e56..da25f0cc59 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,8 @@ "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", + "tests/agent-preset-selection.e2e.ts", + "tests/agent-preset-authoring.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index fe32a219de..22c74a441e 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: 771d7489ee338db56362a6ccc133b1ebf8cdc7c0 -architecture.zh.md: ca7c4fe2a463e01a8e14e63a53f13aea45cbfa16 +architecture.md: ebf05397cb67cea336dd36a4d9416d43b6002d4e +architecture.zh.md: fec9a00484c495b0eed4773f262bb44543e304bd diff --git a/docs/architecture.md b/docs/architecture.md index 771d7489ee..ebf05397cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | -| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent front doors | +| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent entry points | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -164,7 +164,11 @@ Exceptions combine LLM Service Definition/Consumer roles, filesystem policy, web ### Bundles And Apps -`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC entry points ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). + +### Agent Presets + +A deployment may compose each session's model-facing plugin set separately. An **agent preset** is a directory holding one `agent.cordis.yml`, mounted as an `include` subtree under that agent's scope during `setup(agentCtx)`, so its tool and prompt registrations file into that agent's layer and unwind with it — no new tier in the registries. The host composition keeps what must be shared: the registries themselves, cross-session facilities, the sandbox and approval stack, the model route. `ctx.agentPresets` owns discovery and the guarded mount, rejecting a row that never activates or that publishes into the root service realm. Details: [per-session agent presets](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md), [preset/](../packages/preset/README.md). ### Where New Behavior Goes @@ -174,6 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register its adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas join prompt assembly | +| Give one session a different capability set | compose it in an agent preset; a service row there needs an `isolate` realm | | Add shell execution | implement and register a `ctx.bash` backend; the local backend spawns through `ctx.subprocess` | | Add persistent terminal execution | register a `ctx.pty` backend plus `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch without a model turn | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ca7c4fe2a4..fec9a00484 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -166,6 +166,10 @@ idle inject: `dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[interaction/](../packages/interaction/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 +### Agent Preset + +部署可为每个会话分别组装面向模型的插件集合。**agent preset** 是一个含 `agent.cordis.yml` 的目录,在 `setup(agentCtx)` 期间作为 `include` 子树挂到该 agent 的 scope 之下,其工具与提示词注册因而归档进该 agent 的分层并随之卸载,注册表无需新增层级。宿主组装保留必须共享的部分:注册表本身、跨会话设施、沙箱与审批栈、模型路由。`ctx.agentPresets` 负责发现与把关,拒绝未激活的行和把服务发布进根 realm 的行。详见 [按会话组装 agent preset](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)、[preset/](../packages/preset/README.md)。 + ### 新行为的归属位置 新行为附加到已有文档记录的扩展点;循环发生变更时,本架构图随之更新。 @@ -174,6 +178,7 @@ idle inject: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册其适配器 | | 添加面向模型的能力 | 在 `ctx.tools` 上注册;schema 加入提示词组装 | +| 让某个会话拥有不同的能力集合 | 在 agent preset 中组装它;其中的 service 行需要 `isolate` realm | | 添加 shell 执行 | 实现并注册 `ctx.bash` 后端;本地后端通过 `ctx.subprocess` spawn 进程 | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index aa09cb3036..085d993fef 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/capability-seams.md -capability-seams.md: af9f8ba48e67074a485019a4ad9dddd08b2faf81 -capability-seams.zh.md: 7dff963646991d8b1f763ed789109e93cce2ddb8 +capability-seams.md: 345e17c8c28bbe3465abd20639770e6331a70d11 +capability-seams.zh.md: 472aaabf4c992fdcd54fbe0e9a14cf9a82e6b803 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index af9f8ba48e..345e17c8c2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -81,6 +81,8 @@ flowchart LR svc_userInteraction["ctx.userInteraction<br/>Human question/answer seam"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode<br/>Plan collaboration state"] + pkg_agent_presets["agent-presets"] + svc_agentPresets["ctx.agentPresets<br/>Per-session agent composition"] pkg_commands["commands"] svc_commands["ctx.commands<br/>Human command registry"] pkg_session_projection["session-projection"] @@ -181,6 +183,7 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop + pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval pkg_bash --> svc_bash @@ -396,14 +399,15 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/self-modification/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | +| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7dff963646..472aaabf4c 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -83,6 +83,8 @@ flowchart LR svc_userInteraction["ctx.userInteraction<br/>Human question/answer seam"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode<br/>Plan collaboration state"] + pkg_agent_presets["agent-presets"] + svc_agentPresets["ctx.agentPresets<br/>Per-session agent composition"] pkg_commands["commands"] svc_commands["ctx.commands<br/>Human command registry"] pkg_session_projection["session-projection"] @@ -183,6 +185,7 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop + pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval pkg_bash --> svc_bash @@ -398,14 +401,15 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm)、[`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop)、[`tools`](../packages/core/tools)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop)、[`tool-ask-user`](../packages/interaction/tool-ask-user)、[`tool-bash`](../packages/bash/tool-bash)、[`tool-cordis`](../packages/self-modification/tool-cordis)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-skill`](../packages/skill/tool-skill)、[`tool-subagent`](../packages/subagent/tool-subagent)、[`tool-todo`](../packages/todo/tool-todo)、[`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 Code Mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 入口提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | +| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo)、[`session-title`](../packages/session/session-title)、[`host-apiproxy`](../packages/host/apiproxy) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,api-proxy 提供基线并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge)、[`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop)、[`acp`](../packages/acp/acp)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接前门与 Host 支撑的 Agent 前门共享同一个状态所有者。 | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b)、[`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 80ac35ca4e..60c6e85cca 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-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 docs/config-catalog.md -config-catalog.md: 10f0761fc5aa69852dff06f340f83f5a916975a9 -config-catalog.zh.md: ec0e44e9d39b801a5987f2bdab2584370c1b9333 +config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 +config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 10f0761fc5..18980d22c6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -124,6 +124,37 @@ Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/cor Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) +## `@deepseek-ai/dsh-agent-presets` + +Requires: `loader` + +```ts config-catalog +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' +``` + +Source: [`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts) + ## `@deepseek-ai/dsh-agent-spine-demo` ```ts config-catalog @@ -208,6 +239,28 @@ Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfi Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +## `@deepseek-ai/dsh-agent-tool-mode` + +Requires: `tools` + +```ts config-catalog +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a preset + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} +``` + +Depends on: [`ToolPresentationMode`](subsystems/tools.md) + +Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) + ## `@deepseek-ai/dsh-bash-env` ```ts config-catalog @@ -571,6 +624,14 @@ Requires: `agentDefaultModel` · `agents` · `directoryPicker` · `llm` · `sess export interface Config { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } ``` @@ -1050,6 +1111,24 @@ Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsys Source: [`packages/interaction/permission/src/index.ts:140`](../packages/interaction/permission/src/index.ts) +## `@deepseek-ai/dsh-persona` + +Requires: `systemPrompt` + +```ts config-catalog +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} +``` + +Source: [`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/src/index.ts) + ## `@deepseek-ai/dsh-plan-mode` Requires: `tools` · `systemPrompt` @@ -1138,6 +1217,26 @@ export interface Config { Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) +## `@deepseek-ai/dsh-pwsh-sandbox` + +Requires: `subprocess` · `sandbox` · `sandboxPolicy` + +```ts config-catalog +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig +``` + +Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local) + +Source: [`packages/bash/pwsh-sandbox/src/index.ts:40`](../packages/bash/pwsh-sandbox/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1214,7 +1313,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -1514,7 +1613,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:266`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:279`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1867,7 +1966,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:166`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2070,7 +2169,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts) +Source: [`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` @@ -2303,11 +2402,16 @@ Requires: `systemPrompt` /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a `ctx.codeRuntime` whose `language` has a registered - * SDK renderer (TypeScript or Python) and fail prompt assembly when it is - * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * `ctx.codeRuntime` whose `language` has a registered SDK renderer + * (TypeScript or Python) and fail prompt assembly when it is absent or has + * no renderer. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** @@ -2581,6 +2685,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@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-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/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)) - `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) @@ -2590,7 +2695,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) @@ -2672,6 +2777,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) +- `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/scaffold/scripts/src/index.ts`](../packages/scaffold/scripts/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/scaffold/client/src/index.ts`](../packages/scaffold/client/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ec0e44e9d3..a43c561806 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -126,6 +126,37 @@ export interface Config { 来源:[`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) +## `@deepseek-ai/dsh-agent-presets` + +需要:`loader` + +```ts config-catalog +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' +``` + +来源:[`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts) + ## `@deepseek-ai/dsh-agent-spine-demo` ```ts config-catalog @@ -210,6 +241,28 @@ export interface GoalConfig { 来源:[`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +## `@deepseek-ai/dsh-agent-tool-mode` + +需要:`tools` + +```ts config-catalog +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a preset + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} +``` + +依赖:[`ToolPresentationMode`](subsystems/tools.md) + +来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) + ## `@deepseek-ai/dsh-bash-env` ```ts config-catalog @@ -573,6 +626,14 @@ export interface Config { export interface Config { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } ``` @@ -1052,6 +1113,24 @@ export interface PresetSpec { 来源:[`packages/interaction/permission/src/index.ts:140`](../packages/interaction/permission/src/index.ts) +## `@deepseek-ai/dsh-persona` + +需要:`systemPrompt` + +```ts config-catalog +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} +``` + +来源:[`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/src/index.ts) + ## `@deepseek-ai/dsh-plan-mode` 需要:`tools` · `systemPrompt` @@ -1140,6 +1219,26 @@ export interface Config { 来源:[`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) +## `@deepseek-ai/dsh-pwsh-sandbox` + +需要:`subprocess` · `sandbox` · `sandboxPolicy` + +```ts config-catalog +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig +``` + +依赖:[`LocalConfig`](#deepseek-aidsh-pwsh-local) + +来源:[`packages/bash/pwsh-sandbox/src/index.ts:40`](../packages/bash/pwsh-sandbox/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1216,7 +1315,7 @@ export interface Config { } ``` -来源:[`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) +来源:[`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -1516,7 +1615,7 @@ export interface Config { } ``` -来源:[`packages/skill/skill/src/index.ts:266`](../packages/skill/skill/src/index.ts) +来源:[`packages/skill/skill/src/index.ts:279`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1869,7 +1968,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:166`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2071,7 +2170,7 @@ export interface Config { } ``` -来源:[`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts) +来源:[`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` @@ -2304,11 +2403,16 @@ export interface Config { /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a `ctx.codeRuntime` whose `language` has a registered - * SDK renderer (TypeScript or Python) and fail prompt assembly when it is - * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * `ctx.codeRuntime` whose `language` has a registered SDK renderer + * (TypeScript or Python) and fail prompt assembly when it is absent or has + * no renderer. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** @@ -2582,6 +2686,7 @@ export interface Config { - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `httpServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/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-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/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)) - `@deepseek-ai/dsh-client-ui-deliverables`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) @@ -2591,7 +2696,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-models`([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-question` — 需要 `tools` · `userInteraction`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) @@ -2672,6 +2777,7 @@ export interface Config { - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths`([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention`([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) +- `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts`([`packages/scaffold/scripts/src/index.ts`](../packages/scaffold/scripts/src/index.ts)) - `@deepseek-ai/dsh-sdk-client`([`packages/scaffold/client/src/index.ts`](../packages/scaffold/client/src/index.ts)) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index fd29baab75..8fb1893fca 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 38483b5c4993a44562970782dca5f676e4cb84f6 -07-into-the-harness.zh.md: 59ce716bdace894682bc1c8e6e00cf174008c26c +07-into-the-harness.md: 69133786f58541b015aed080f4ac8fb2a7e488c0 +07-into-the-harness.zh.md: bc9c61da984e3eb691eb6bfbe59ae556823e82de diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 38483b5c49..69133786f5 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -95,7 +95,7 @@ The logger fired first: `tools/result` is emitted as part of result materializat ## From here to a full agent -A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. +A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, an entry point. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. Where to go next: diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 59ce716bda..bc9c61da98 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -95,7 +95,7 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 ## 从这里走向完整 agent(智能体) -真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 +真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和运行入口。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 后续可以阅读: diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 1f2e85c3d6..e2c246bdbd 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/event-producer-consumer.md -event-producer-consumer.md: e238734189d6553f9008d958bafbf9556ee23bff -event-producer-consumer.zh.md: b74a1ed334919fd6db1187d7f0e07e2b6ddc5221 +event-producer-consumer.md: 8e4a413f91b94c5f02d004cfb3105f8dae8913f5 +event-producer-consumer.zh.md: 358ec799cedeccd784fa68e7f1ca420d58e1cd09 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e238734189..8e4a413f91 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/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:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -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/interaction/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/interaction/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/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/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b74a1ed334..358ec799ce 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -38,7 +38,7 @@ | `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/session-persistence), [`session-telemetry`](../packages/session/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:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -68,7 +68,7 @@ | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/interaction/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/interaction/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/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/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets)、`gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 61e55cd997..54453b3411 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/module-graph.md -module-graph.md: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 -module-graph.zh.md: 28185c255ffa18f3ebc02f177d20594af3356164 +module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3 +module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa diff --git a/docs/module-graph.md b/docs/module-graph.md index a248ed4fcb..c41db02165 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -27,6 +27,7 @@ flowchart TD pkg_agent["agent"] pkg_agent_default_model["agent-default-model"] pkg_agent_loop["agent-loop"] + pkg_agent_tool_mode["agent-tool-mode"] pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] @@ -44,6 +45,7 @@ flowchart TD pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] + pkg_pwsh_sandbox["pwsh-sandbox"] pkg_tool_bash["tool-bash"] pkg_tool_pwsh["tool-pwsh"] end @@ -141,6 +143,7 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] + pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -221,6 +224,10 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_preset["packages/preset"] + pkg_agent_presets["agent-presets"] + pkg_persona["persona"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -231,6 +238,7 @@ flowchart TD pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] pkg_sandbox_policy["sandbox-policy"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] end subgraph group_scaffold["packages/scaffold"] pkg_helper["helper"] @@ -311,9 +319,9 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_e2b --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_type_meta --> pkg_invariants @@ -379,6 +387,7 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_skill --> pkg_invariants pkg_skill --> pkg_llm + pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -388,11 +397,6 @@ flowchart TD 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 @@ -415,8 +419,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_sandbox --> pkg_invariants - pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths @@ -427,13 +429,6 @@ flowchart TD 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 - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_local --> pkg_invariants @@ -489,9 +484,17 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_persona --> pkg_invariants + pkg_persona --> pkg_system_prompt + pkg_sandbox --> pkg_invariants + pkg_sandbox --> pkg_llm + pkg_sandbox --> pkg_session pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session @@ -516,22 +519,13 @@ flowchart TD 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 - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_policy --> pkg_fs - pkg_fs_policy --> pkg_invariants - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_invariants - pkg_skill_local --> pkg_paths - pkg_skill_local --> pkg_skill + pkg_bash --> pkg_invariants + pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_environment @@ -540,9 +534,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_hook_protocol --> pkg_bash - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm @@ -554,13 +545,8 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants 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 @@ -586,16 +572,13 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm - pkg_lsp_local --> pkg_brand - pkg_lsp_local --> pkg_fs - pkg_lsp_local --> pkg_invariants - pkg_lsp_local --> pkg_llm - pkg_lsp_local --> pkg_lsp - pkg_lsp_local --> pkg_subprocess - pkg_lsp_local --> pkg_timeout pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox @@ -653,21 +636,30 @@ flowchart TD pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy + pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths + pkg_skill_local --> pkg_skill pkg_compact --> pkg_brand pkg_compact --> pkg_commands pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -689,6 +681,18 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session + 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_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -705,6 +709,13 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval + pkg_lsp_local --> pkg_brand + pkg_lsp_local --> pkg_fs + pkg_lsp_local --> pkg_invariants + pkg_lsp_local --> pkg_llm + pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess + pkg_lsp_local --> pkg_timeout pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -734,6 +745,8 @@ flowchart TD pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools + pkg_agent_tool_mode --> pkg_invariants + pkg_agent_tool_mode --> pkg_tools pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_invariants @@ -746,6 +759,21 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -944,9 +972,12 @@ flowchart TD pkg_tool_pwsh --> pkg_bash_env pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_compact_tool_result_prune --> pkg_compact pkg_compact_tool_result_prune --> pkg_invariants pkg_compact_tool_result_prune --> pkg_llm @@ -1058,6 +1089,15 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_runtime + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_primitives + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_slots + pkg_client_ui_agent_preset --> pkg_client_web_react + pkg_client_ui_agent_preset --> pkg_invariants pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -1204,9 +1244,9 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`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) | @@ -1231,21 +1271,17 @@ flowchart TD | [`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), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`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), [`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) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`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` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1260,32 +1296,29 @@ 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) | | [`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) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | +| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`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/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) | -| [`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) | +| [`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) | | [`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) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`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) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`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/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1299,23 +1332,35 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`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) | +| [`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) | | [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`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) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`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) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`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/session-projection) | | [`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/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1348,7 +1393,7 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1367,6 +1412,7 @@ flowchart TD | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`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) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `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-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-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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 28185c255f..9071dbc0f2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -29,6 +29,7 @@ flowchart TD pkg_agent["agent"] pkg_agent_default_model["agent-default-model"] pkg_agent_loop["agent-loop"] + pkg_agent_tool_mode["agent-tool-mode"] pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] @@ -46,6 +47,7 @@ flowchart TD pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] + pkg_pwsh_sandbox["pwsh-sandbox"] pkg_tool_bash["tool-bash"] pkg_tool_pwsh["tool-pwsh"] end @@ -143,6 +145,7 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] + pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -223,6 +226,10 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_preset["packages/preset"] + pkg_agent_presets["agent-presets"] + pkg_persona["persona"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -233,6 +240,7 @@ flowchart TD pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] pkg_sandbox_policy["sandbox-policy"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] end subgraph group_scaffold["packages/scaffold"] pkg_helper["helper"] @@ -313,9 +321,9 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_e2b --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_type_meta --> pkg_invariants @@ -381,6 +389,7 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_skill --> pkg_invariants pkg_skill --> pkg_llm + pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -390,11 +399,6 @@ flowchart TD 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 @@ -417,8 +421,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_sandbox --> pkg_invariants - pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths @@ -429,13 +431,6 @@ flowchart TD 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 - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_local --> pkg_invariants @@ -491,9 +486,17 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_persona --> pkg_invariants + pkg_persona --> pkg_system_prompt + pkg_sandbox --> pkg_invariants + pkg_sandbox --> pkg_llm + pkg_sandbox --> pkg_session pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session @@ -518,22 +521,13 @@ flowchart TD 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 - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_policy --> pkg_fs - pkg_fs_policy --> pkg_invariants - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_invariants - pkg_skill_local --> pkg_paths - pkg_skill_local --> pkg_skill + pkg_bash --> pkg_invariants + pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_environment @@ -542,9 +536,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_hook_protocol --> pkg_bash - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm @@ -556,13 +547,8 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants 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 @@ -588,16 +574,13 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm - pkg_lsp_local --> pkg_brand - pkg_lsp_local --> pkg_fs - pkg_lsp_local --> pkg_invariants - pkg_lsp_local --> pkg_llm - pkg_lsp_local --> pkg_lsp - pkg_lsp_local --> pkg_subprocess - pkg_lsp_local --> pkg_timeout pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox @@ -655,21 +638,30 @@ flowchart TD pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy + pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths + pkg_skill_local --> pkg_skill pkg_compact --> pkg_brand pkg_compact --> pkg_commands pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -691,6 +683,18 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session + 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_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -707,6 +711,13 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval + pkg_lsp_local --> pkg_brand + pkg_lsp_local --> pkg_fs + pkg_lsp_local --> pkg_invariants + pkg_lsp_local --> pkg_llm + pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess + pkg_lsp_local --> pkg_timeout pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -736,6 +747,8 @@ flowchart TD pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools + pkg_agent_tool_mode --> pkg_invariants + pkg_agent_tool_mode --> pkg_tools pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_invariants @@ -748,6 +761,21 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -946,9 +974,12 @@ flowchart TD pkg_tool_pwsh --> pkg_bash_env pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_compact_tool_result_prune --> pkg_compact pkg_compact_tool_result_prune --> pkg_invariants pkg_compact_tool_result_prune --> pkg_llm @@ -1060,6 +1091,15 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_runtime + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_primitives + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_slots + pkg_client_ui_agent_preset --> pkg_client_web_react + pkg_client_ui_agent_preset --> pkg_invariants pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -1184,7 +1224,7 @@ flowchart TD pkg_acp_demo --> pkg_workspace_context ``` -| 包 | 分组 | 依赖项 | +| Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | @@ -1206,9 +1246,9 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`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) | @@ -1233,21 +1273,17 @@ flowchart TD | [`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), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`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), [`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) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`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` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1262,32 +1298,29 @@ 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) | | [`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) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | +| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`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/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) | -| [`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) | +| [`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) | | [`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) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`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) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`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/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1301,23 +1334,35 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`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) | +| [`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) | | [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`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) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`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) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`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/session-projection) | | [`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/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1350,7 +1395,7 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1369,6 +1414,7 @@ flowchart TD | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`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) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `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-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-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) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e5dd4edde7..7ca14e31fb 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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 docs/persistence-catalog.md -persistence-catalog.md: a17cae015eaa107a900069de916dddb216b87ec7 -persistence-catalog.zh.md: 3aef073dedcff0b6addb99d7c287f4e5f372c402 +persistence-catalog.md: 9953214182521ac2c1aac8b4589bad7ad45e3094 +persistence-catalog.zh.md: 730513ea259dde274c8c63948dd21fdc0b70417f diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a17cae015e..9953214182 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) ## Events @@ -104,6 +104,22 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +### `agent-preset/*` + +#### `agent-preset/selected` — log-only + +```ts persistence-catalog +/** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ +'agent-preset/selected': { agentPreset: string } +``` + +Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts) + ### `approval/*` #### `approval/asked` — log-only @@ -176,7 +192,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -192,7 +208,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) ### `command/*` @@ -472,7 +488,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -484,7 +500,7 @@ Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -537,7 +553,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record<string, never> ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -573,7 +589,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -582,7 +598,7 @@ Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -612,7 +628,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `tool/*` @@ -629,7 +645,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -698,7 +714,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `turn/*` @@ -718,7 +734,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -732,7 +748,7 @@ Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) ### `user/*` @@ -749,7 +765,7 @@ Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 3aef073ded..730513ea25 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -81,7 +81,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) ## 事件 @@ -106,6 +106,22 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +### `agent-preset/*` + +#### `agent-preset/selected` — log-only + +```ts persistence-catalog +/** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ +'agent-preset/selected': { agentPreset: string } +``` + +来源:[`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts) + ### `approval/*` #### `approval/asked` — log-only @@ -178,7 +194,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 类型:[StreamChunk](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -194,7 +210,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) ### `command/*` @@ -474,7 +490,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -486,7 +502,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -539,7 +555,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'session/end-seed': Record<string, never> ``` -来源:[`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -575,7 +591,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -584,7 +600,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -614,7 +630,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 类型:[TodoItem](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `tool/*` @@ -631,7 +647,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 类型:[CallId](subsystems/core.md) -来源:[`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -700,7 +716,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { } ``` -来源:[`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `turn/*` @@ -720,7 +736,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 类型:[TurnEndReason](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -734,7 +750,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) ### `user/*` @@ -751,7 +767,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 70ceac3b11..0b2b87a954 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/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/subsystems/core.md -core.md: 27c4359e360223d336cd94695bb45a79f0fd370c -core.zh.md: 4e7519665b6d9efb8075546d93325debd961905d +core.md: af27484160769156836f377e5b3aba2521280005 +core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 27c4359e36..af27484160 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -330,7 +330,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> @@ -377,6 +377,138 @@ Types: [SessionHeader](persistence.md) Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-loop/src/index.ts) +<a id="ctxagentpresets--agentpresets"></a> + +### `ctx.agentPresets` — `AgentPresets` + +Registry over the deployment's agent presets. + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. + +```ts cordis-catalog +/** + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. + */ +async list(): Promise<AgentPreset[]> + +/** + * Resolve one preset by id. + * + * A broken preset resolves — deleting one, reading one, and reporting one + * all need the row — and the mounting paths refuse it AFTER resolution + * through {@link resolveMountable}. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. + * @throws when no configured root supplies that id. + */ +async resolve(id?: string): Promise<AgentPreset> + +/** + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken preset never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was composed, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ +async mount(agentCtx: Context, id?: string): Promise<AgentPreset> + +/** + * Read one preset's composition text. + * @param id - the preset id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ +async read(id: string): Promise<string> + +/** + * Create a locally authored preset by copying an existing one whole. + * + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. + */ +async copy(from: string, id: string, name?: string): Promise<void> + +/** + * Delete a locally authored preset. + * @param id - the preset id. + * @throws when the preset is unknown or ships with the deployment. + */ +async remove(id: string): Promise<void> + +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined + +/** + * Re-link one agent to a different preset's standing composition. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. + * + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable. + */ +async recompose(agentCtx: Context, id: string): Promise<AgentPreset> + +/** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ +async standingKeyFor(id?: string): Promise<ScopeKey> +``` + +Types: [ScopeKey](scope.md) + +Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) + <a id="ctxagents--agentregistry"></a> ### `ctx.agents` — `AgentRegistry` @@ -547,7 +679,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:255`](../../packages/core/agent/src/index.ts) <a id="agent-events"></a> diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 4e7519665b..12935f4d88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -338,7 +338,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> @@ -385,6 +385,138 @@ Types: [SessionHeader](persistence.md) Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-loop/src/index.ts) +<a id="ctxagentpresets--agentpresets"></a> + +### `ctx.agentPresets` — `AgentPresets` + +Registry over the deployment's agent presets. + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. + +```ts cordis-catalog +/** + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. + */ +async list(): Promise<AgentPreset[]> + +/** + * Resolve one preset by id. + * + * A broken preset resolves — deleting one, reading one, and reporting one + * all need the row — and the mounting paths refuse it AFTER resolution + * through {@link resolveMountable}. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. + * @throws when no configured root supplies that id. + */ +async resolve(id?: string): Promise<AgentPreset> + +/** + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken preset never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was composed, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ +async mount(agentCtx: Context, id?: string): Promise<AgentPreset> + +/** + * Read one preset's composition text. + * @param id - the preset id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ +async read(id: string): Promise<string> + +/** + * Create a locally authored preset by copying an existing one whole. + * + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. + */ +async copy(from: string, id: string, name?: string): Promise<void> + +/** + * Delete a locally authored preset. + * @param id - the preset id. + * @throws when the preset is unknown or ships with the deployment. + */ +async remove(id: string): Promise<void> + +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined + +/** + * Re-link one agent to a different preset's standing composition. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. + * + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable. + */ +async recompose(agentCtx: Context, id: string): Promise<AgentPreset> + +/** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ +async standingKeyFor(id?: string): Promise<ScopeKey> +``` + +Types: [ScopeKey](scope.md) + +Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) + <a id="ctxagents--agentregistry"></a> ### `ctx.agents` — `AgentRegistry` @@ -555,7 +687,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:255`](../../packages/core/agent/src/index.ts) <a id="agent-events"></a> diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index f198d0a25e..9e0a373532 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/subsystems/persistence.md -persistence.md: 640e2c0122b01ae869d20c1805742abad782c95b -persistence.zh.md: 7e28d6d1f63a78741840fb74194d3249696423ae +persistence.md: 8f6872b77be8c7ae273e0fc1887dca30dbe1eb37 +persistence.zh.md: 25e72a69bd03cd5cc0715ae769055062466397dd diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 640e2c0122..8f6872b77b 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -77,12 +77,19 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 7e28d6d1f6..25e72a69bd 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -77,12 +77,19 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index a6f8fd2609..32bedb3e94 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: dd960b3021dcdc87cfd36fd439cbec0a810dd736 -sandbox.zh.md: 23644bb43a131a0e3c8595187a6fc11e74682d9e +sandbox.md: 20e0f36a5edb211ea409208d4e5e4a9be2e91d46 +sandbox.zh.md: 5f5465af46aa88d72b4a39f728f18855156b24ba diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index dd960b3021..20e0f36a5e 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -8,7 +8,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ## Modes and enforcement -`SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. +`SandboxMode` governs filesystem effects only. `read-only` denies every write — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants nothing; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. ```ts type-equiv /** @@ -53,6 +53,14 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. + */ + sessionId?: SessionId } ``` @@ -176,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) <a id="ctxsandboxpolicy--sandboxpolicyservice"></a> diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 23644bb43a..5f5465af46 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -8,7 +8,7 @@ ## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入(必需的 `/dev/null` 接收器除外);`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何写入;`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv /** @@ -53,6 +53,14 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. + */ + sessionId?: SessionId } ``` @@ -176,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) <a id="ctxsandboxpolicy--sandboxpolicyservice"></a> diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 823ed61107..e3e42cdc9e 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: d91a3faa50dc092d89aa2c7d5ce1e6118df7ebd6 -session-projection.zh.md: 7dac3db8470a2941b711db6a30fced8dbe7c7a8d +session-projection.md: 4cbe0babb22406f7a48f0c19e982bb4757b4f44d +session-projection.zh.md: 5eada67a6eed914021e284fc5eabf203125b4b83 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index d91a3faa50..4cbe0babb2 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -154,7 +154,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:156`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 7dac3db847..5eada67a6e 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -154,7 +154,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:156`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 3b9f9ce01d..e9d7a2f936 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/subsystems/session.md -session.md: f5b9e63e2320885cc41b30a09398dd341700152d -session.zh.md: 985e0a448d1cf860ccbb0f2885d855ad6830af9f +session.md: 6fb0cec4fd222ceafbd5b4111fe56f22505058ad +session.zh.md: d33a71e92e2bd9fd9fb7e9194255b7c1f5f0af77 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f5b9e63e23..6fb0cec4fd 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -733,7 +733,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) <a id="session-events"></a> diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 985e0a448d..d33a71e92e 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -737,7 +737,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) <a id="session-events"></a> diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index a169374140..6f089b7ff4 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/subsystems/skills.md -skills.md: 696f759179203230bf748c8a4be2cee333acd9a3 -skills.zh.md: d2244e86f0ceaf77c1eab02508dc3f07df727dbb +skills.md: f222da732ba3800a214e236bb7a64d5709067cdb +skills.zh.md: f20c68596f5350ac33c2956dfb124ae6c8972882 diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 696f759179..f222da732b 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -2,7 +2,7 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service provider ([dsh-skill-local](../../packages/skill/skill-local)), the optional packaged badge provider ([dsh-skill-badge](../../packages/skill/skill-badge)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service provider ([dsh-skill-local](../../packages/skill/skill-local)), the optional packaged badge provider ([dsh-skill-badge](../../packages/skill/skill-badge)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). @@ -10,7 +10,9 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind `ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. -Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation, while an explicit incomplete observation contributes usable candidates without making the result cacheable; malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries once when its provider generation changes; a second change returns the latest candidates incomplete and uncached. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. +The registry is host+per-scope layered, the shape the [tools registry](tools.md) established over [dsh-scope](../../packages/core/scope): a registration files into the layer of its calling context's scope, so host rows and repository plugins land in the global layer while a plugin mounted by an agent preset's standing composition lands in that preset's layer, and provider names are unique per layer rather than process-wide. A read merges the global layer with the viewing scope's chain — the nearest layer's entry wins a duplicate skill name outright, and the rank order below decides duplicates only within one layer. Discovery caches are keyed by the resolved scope chain, so re-parenting a scope (a blank-session recompose) is visible to the next read without a registry mutation. + +Within one layer, duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation, while an explicit incomplete observation contributes usable candidates without making the result cacheable; malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries once when its provider generation changes; a second change returns the latest candidates incomplete and uncached. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. An array returned by `SkillProvider.list()` is complete-discovery shorthand. `SkillProviderObservation` lets a provider expose candidates that remain directly loadable while reporting that the observation is not authoritative. @@ -187,7 +189,7 @@ type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & { ## Lookup and configuration -Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. +Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Registry reads additionally take the viewing scope — consumers pass the calling agent, which is its own scope key — through `SkillViewOptions`; the registry consumes `scope` for layer selection, and providers read only their `SkillLookupOptions` contract from the same borrowed options object. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. Full definitions are not cached by the registry. Each `get()` calls the winning provider with the selected candidate, so the local provider rereads the current body. A definition whose name no longer matches that candidate is rejected and invalidates the exact provider for rediscovery. @@ -201,6 +203,19 @@ interface SkillLookupOptions { } ``` +```ts type-equiv +/** + * Registry read options: provider lookup context plus the viewing scope. + * The registry consumes `scope` to select layers; providers receive the same + * borrowed options object and read only their {@link SkillLookupOptions} + * contract from it. + */ +interface SkillViewOptions extends SkillLookupOptions { + /** Viewing scope (the calling agent); omitted reads the global layer alone. */ + readonly scope?: ScopeKey | undefined +} +``` + The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`) plus watcher enablement, polling, stability, symlink, and project-capacity controls. The consumer owns its catalog description bound. Exact defaults and validation are in the generated [config catalog](../config-catalog.md). ```ts type-equiv @@ -231,13 +246,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.skills` — `SkillService` -Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. +Layered registry of skill providers, the host+per-scope shape the tools registry established. A registration files into the layer of its calling context's scope (scopeOf): host rows and repository plugins land in the global layer, while a plugin mounted by an agent preset's standing composition lands in that preset's layer. A read merges the global layer with the viewing scope's chain — the nearest layer's entry wins a duplicate name outright, and the rank order decides duplicates only within one layer. It exposes sorted invocation-neutral summaries and loads full skill bodies on demand. ```ts cordis-catalog /** - * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and - * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters - * the provider and invalidates catalog caches. + * Register a borrowed same-process provider synchronously during plugin + * apply, into the calling context's layer: a scoped context (an agent + * preset's standing mount) registers for that scope alone, an unscoped + * context registers globally. Duplicate names within one layer and reserved + * names throw; remote initialization belongs in `list()`. Fiber disposal + * unregisters the provider and invalidates catalog caches. * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. @@ -245,9 +263,11 @@ Registry of skill providers. It merges provider catalogs with stable first-wins registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** - * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which - * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and - * receives a no-op disposer so it cannot remove the winner. + * Register a borrowed readonly runtime skill into the calling context's + * layer. Project entries outrank runtime entries, which outrank user + * entries, within one layer. Same-name runtime entries in one layer are + * first-wins; a duplicate logs a warning and receives a no-op disposer so + * it cannot remove the winner. * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ @@ -258,32 +278,33 @@ register(skill: SkillRegistration): () => void * model or user invocation policy at their operational boundary. Lookup * options and provider candidates are readonly same-process values borrowed * throughout discovery. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns all sorted winning summaries. */ -async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> +async list(options: SkillViewOptions = {}): Promise<SkillSummary[]> /** * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns sorted summaries plus discovery-completeness state. */ -async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> +async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot> /** * Load and validate the winning candidate, passing its opaque discovery locator back to the * provider. Cancellation is rechecked after selection, including cache hits, and raced against * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. - * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @param options - view options; `scope` selects the viewing agent's layers, + * `cwd` selects workspace-sensitive skills, and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ -async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> +async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined> ``` -Source: [`packages/skill/skill/src/index.ts:305`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:357`](../../packages/skill/skill/src/index.ts) <a id="skills-events"></a> @@ -306,5 +327,5 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:284`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:297`](../../packages/skill/skill/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index d2244e86f0..f20c68596f 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -2,7 +2,7 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service provider([dsh-skill-local](../../packages/skill/skill-local))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service provider([dsh-skill-local](../../packages/skill/skill-local))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 @@ -10,7 +10,9 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名项依次按 rank、提供方顺序和本地顺序确定优先级;摘要按名称排序。提供方的 `list()` 被拒绝时,系统会记录日志,并从不完整观测中省略该提供方的结果;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +注册表采用宿主 + 按 scope 的分层结构,即[工具注册表](tools.md)在 [dsh-scope](../../packages/core/scope) 之上确立的形态:注册会落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,由 agent preset 常驻组合挂载的插件落入该 preset 的层——提供方名称在每层内唯一,而非进程级唯一。读取时将全局层与观察 scope 的链合并:最近层的条目直接赢得重名 skill,下文的 rank 顺序只在单层内裁决重名。发现缓存以解析后的 scope 链为键,因此重设 scope 父级(空会话重组)无需注册表变更即可被下一次读取看到。 + +在单层内,重名项依次按 rank、提供方顺序和本地顺序确定优先级;摘要按名称排序。提供方的 `list()` 被拒绝时,系统会记录日志,并从不完整观测中省略该提供方的结果;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 `SkillProvider.list()` 返回的数组是完整发现的简写形式。`SkillProviderObservation` 允许提供方公开仍可直接加载的候选项,同时报告该观测不具权威性。 @@ -187,7 +189,7 @@ type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & { ## 查找与配置 -skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收用于缓存标识和加载的同一个只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 +skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。注册表读取还通过 `SkillViewOptions` 携带观察 scope——消费方传入调用中的 agent,agent 本身就是自己的 scope key;注册表消费 `scope` 做层选择,提供方只从同一个借用的选项对象中读取其 `SkillLookupOptions` 契约。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。 @@ -201,6 +203,19 @@ interface SkillLookupOptions { } ``` +```ts type-equiv +/** + * Registry read options: provider lookup context plus the viewing scope. + * The registry consumes `scope` to select layers; providers receive the same + * borrowed options object and read only their {@link SkillLookupOptions} + * contract from it. + */ +interface SkillViewOptions extends SkillLookupOptions { + /** Viewing scope (the calling agent); omitted reads the global layer alone. */ + readonly scope?: ScopeKey | undefined +} +``` + 注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`),以及 watcher 启用、轮询、稳定性、符号链接和项目容量控制。消费方拥有其目录描述上限。确切的默认值和校验规则见自动生成的[插件配置目录](../config-catalog.md)。 ```ts type-equiv @@ -231,13 +246,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.skills` — `SkillService` -Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. +Layered registry of skill providers, the host+per-scope shape the tools registry established. A registration files into the layer of its calling context's scope (scopeOf): host rows and repository plugins land in the global layer, while a plugin mounted by an agent preset's standing composition lands in that preset's layer. A read merges the global layer with the viewing scope's chain — the nearest layer's entry wins a duplicate name outright, and the rank order decides duplicates only within one layer. It exposes sorted invocation-neutral summaries and loads full skill bodies on demand. ```ts cordis-catalog /** - * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and - * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters - * the provider and invalidates catalog caches. + * Register a borrowed same-process provider synchronously during plugin + * apply, into the calling context's layer: a scoped context (an agent + * preset's standing mount) registers for that scope alone, an unscoped + * context registers globally. Duplicate names within one layer and reserved + * names throw; remote initialization belongs in `list()`. Fiber disposal + * unregisters the provider and invalidates catalog caches. * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. @@ -245,9 +263,11 @@ Registry of skill providers. It merges provider catalogs with stable first-wins registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** - * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which - * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and - * receives a no-op disposer so it cannot remove the winner. + * Register a borrowed readonly runtime skill into the calling context's + * layer. Project entries outrank runtime entries, which outrank user + * entries, within one layer. Same-name runtime entries in one layer are + * first-wins; a duplicate logs a warning and receives a no-op disposer so + * it cannot remove the winner. * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ @@ -258,32 +278,33 @@ register(skill: SkillRegistration): () => void * model or user invocation policy at their operational boundary. Lookup * options and provider candidates are readonly same-process values borrowed * throughout discovery. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns all sorted winning summaries. */ -async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> +async list(options: SkillViewOptions = {}): Promise<SkillSummary[]> /** * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns sorted summaries plus discovery-completeness state. */ -async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> +async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot> /** * Load and validate the winning candidate, passing its opaque discovery locator back to the * provider. Cancellation is rechecked after selection, including cache hits, and raced against * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. - * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @param options - view options; `scope` selects the viewing agent's layers, + * `cwd` selects workspace-sensitive skills, and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ -async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> +async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined> ``` -Source: [`packages/skill/skill/src/index.ts:305`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:357`](../../packages/skill/skill/src/index.ts) <a id="skills-events"></a> @@ -306,5 +327,5 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:284`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:297`](../../packages/skill/skill/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index a7a5e5a6d0..91ff1b49d6 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/subsystems/system-prompt.md -system-prompt.md: 94ce40f8bf98dd4efe3514879c2527c2a7bd3b21 -system-prompt.zh.md: c46ee6e2b70c6603500bd9061ed09b805ed61630 +system-prompt.md: 5397858ea9991efad06e045118b96a90386f2285 +system-prompt.zh.md: defd8fae73834ba543ae1f45d15ca4253a5abe40 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 94ce40f8bf..5397858ea9 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -139,7 +139,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise<PromptAssembly> ``` -Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) <a id="system-prompt-events"></a> diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index c46ee6e2b7..defd8fae73 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -139,7 +139,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise<PromptAssembly> ``` -Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) <a id="system-prompt-events"></a> diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 1384b3888b..ed9d60978f 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/subsystems/tools.md -tools.md: 83389f39c3188fc251504ed5786249ff1921acae -tools.zh.md: 45cb85b3f2940f84b46bc58406bc8255cbe08be7 +tools.md: 692bafa02e37e1c1918fda31c766ad32f7c7cdba +tools.zh.md: 81aabddd20e2a0d09d904f4f8521d65c2622351f diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 83389f39c3..692bafa02e 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -478,6 +478,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Present this agent's tools in `mode` instead of the deployment default. + * + * Scoped only, and one declaration per agent: this is how an agent preset + * composes a Code Mode agent beside native ones in the same process, and a + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ +presentAs(mode: ToolPresentationMode): () => void + /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -554,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 45cb85b3f2..81aabddd20 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -478,6 +478,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Present this agent's tools in `mode` instead of the deployment default. + * + * Scoped only, and one declaration per agent: this is how an agent preset + * composes a Code Mode agent beside native ones in the same process, and a + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ +presentAs(mode: ToolPresentationMode): () => void + /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -554,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 3e74bbc3e6..567f95d408 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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 docs/tool-catalog.md -tool-catalog.md: 58267e208d919e7fa817991207a44ac0864fa00e -tool-catalog.zh.md: 1eda3506644bae6da56895c366a56b566536ed62 +tool-catalog.md: dbab9ce2f389dbfe40e7d753ced995a8a384be17 +tool-catalog.zh.md: e99f8bc78923e616265427c1e0361c832cc0930f diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 58267e208d..dbab9ce2f3 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -212,7 +212,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `pwsh` -Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. +Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1eda350664..e99f8bc789 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -214,7 +214,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac ### `pwsh` -执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 task id;使用 `task_output` 读取输出,使用 `task_kill` 停止任务。 +执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 task id;使用 `task_output` 读取输出,使用 `task_kill` 停止任务。 ```json { diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index f4a719fdb2..c95a0db6c0 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: 29575fcc860721d35588d82a15d9b97f1b7dea0e -providers.zh.md: 4b5fd32224fbfe5b685f886375b590fa3704505d +providers.md: 4667e54161e77a62d454f3f78a8164d5c79b78c8 +providers.zh.md: 15e0ccc826a5c285c71d4776793f8048c11f6254 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 29575fcc86..4667e54161 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct front doors and Host-backed front doors read that same service. +After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct entry points and Host-backed entry points read that same service. If the provider a saved default names is later removed, the composer says **Select model** and refuses input until you pick one, rather than sending to a route nothing serves. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 4b5fd32224..15e0ccc826 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接前门与 Host 支撑的前门都读取同一服务。 +会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接入口与 Host 支撑的入口都读取同一服务。 如果某个已存默认值指向的提供方后来被删掉了,输入框会显示**选择模型**并拒绝输入,而不是把消息发给一个没人服务的路由。 diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 5e6a21e1ec..49614b8c2d 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -24,7 +24,7 @@ flowchart LR cfg --> plugin_acp_acp_agent plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_acp_acp_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_acp_acp_agent --> frontdoor_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] + plugin_acp_acp_agent --> entrypoint_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 20f05623ad..6d339a4512 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\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 }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record<string, never>;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n 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 }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\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 }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record<string, never>;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n 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 }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl index f9e30bb24d..de0171618e 100644 --- a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785916902468,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785916902468,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d588acd6-d0ab-43c5-9e18-67fe3f625e48"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785916902469,"data":{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}} -{"type":"tool/result","seq":16,"time":1785916902487,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785916902487,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785916902487,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785916902496,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1785304900018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 07a1dc582a..90dd1c4f2a 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/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/headless-agent/README.md -README.md: 6e80e56dec70c2be341ae5dfbad70e13a5109715 +README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 6e80e56dec..f12a56920c 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product front door. +This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product entry point. ## Run it diff --git a/knip.json b/knip.json index 9a091a068e..d47c2493a9 100644 --- a/knip.json +++ b/knip.json @@ -5,10 +5,12 @@ ], "ignoreBinaries": [ "bwrap", + "icacls", "musl-gcc", "python3", "sandbox-exec", - "taskkill" + "taskkill", + "where.exe" ], "ignoreWorkspaces": [ "vendor/*", @@ -231,6 +233,16 @@ "tests/**/*.ts" ] }, + "packages/bash/pwsh-sandbox": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/e2b/e2b": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 27f5c79ca4..b3d68bc8f3 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: eff1d9522e3ca6e8a7efaa20463d73036101f8f5 -README.zh.md: cc2d37d3999e2e59095a8000feb3f963d0b4e4a1 +README.md: c18a46b7131f7782be68f3c96fa99b89615de471 +README.zh.md: 3cd766ed70b7847bef8229a48b65873540365851 diff --git a/packages/README.md b/packages/README.md index eff1d9522e..c18a46b713 100644 --- a/packages/README.md +++ b/packages/README.md @@ -34,6 +34,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | +| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | | [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index cc2d37d399..3cd766ed70 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -34,6 +34,7 @@ | [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 | +| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 | | [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 | diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index 71d7a76379..db765f3dde 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -22,8 +22,20 @@ export type ApiRemoteAgentResult = export interface ApiRemoteAgentOptions { /** 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 + /** + * Build the Host-specific Agent-scope composition completed before + * publication. Keyed by the resumed session itself because what a Host + * installs may depend on what that session recorded: an agent preset fixes + * the tools its history was produced under, so rebuilding it under another + * composition would replay tool calls the agent can no longer make. The + * events come along because a session's own record of such a choice may be + * an event rather than a header field. + * @param session - the resumed session's persisted header and event log. + * @returns the Agent-scope setup to run before publication. + */ + readonly setup?: ( + session: { meta: SessionHeader; events: readonly SessionEvent[] }, + ) => AgentSetup | Promise<AgentSetup> } /** Cold identity absent from the durable session store. */ @@ -136,6 +148,11 @@ export function createApiRemoteAgentResolver( if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) { throw new ApiRemoteSubagentSessionOwnership(sessionId) } + // Built from the inspected session before the published re-checks + // below, so those stay adjacent to `resume` and a Host setup that + // awaits (composing a preset, say) does not widen the collision + // window. + const setup = options.setup === undefined ? undefined : await options.setup(inspected) const publishedSession = ctx.sessions.get(sessionId) const publishedAgent = ctx.agents.get(sessionId) if (publishedSession !== undefined @@ -145,7 +162,7 @@ export function createApiRemoteAgentResolver( const handle = await ctx.agents.resume({ resumeSessionId: sessionId, ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() }, - ...options.setup === undefined ? {} : { setup: options.setup }, + ...setup === undefined ? {} : { setup }, }) return handle.agent } finally { diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index f654ea45ed..5983500772 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + /** + * The pwsh invocation argv for one resolved spec — the argv-level seam a + * confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of + * `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see + * `@deepseek-ai/dsh-pwsh-sandbox`). + */ + protected argv(spec: BashExecSpec): string[] { + return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`] + } + + /** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */ + private spawnSpec( + spec: BashExecSpec, + stdoutMaxBytes: number, + signal: AbortSignal | undefined, + argv: readonly string[], + ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { - argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], + argv: [...argv], cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', @@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor { } async run(spec: BashExecSpec): Promise<BashRunResult> { + return this.runArgv(spec, this.argv(spec)) + } + + /** Foreground run of an exact argv (the confining subclass re-wraps it). */ + protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv)) const outcome = await handle.done const collected = PwshLocalExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. @@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor { } start(spec: BashExecSpec): BashProcess { + return this.startArgv(spec, this.argv(spec)) + } + + /** Background start of an exact argv (the confining subclass re-wraps it). */ + protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing @@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, collected.stderr.readFrom(0).text) + this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote) + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. * The base implementation is intentionally empty. Mirrored from - * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is - * the protected extension point for a future pwsh-confining subclass and has no consumer - * in this package yet. + * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the + * pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + * @param _spawnFailed - whether the spawn rejected before any process existed. + * @param _spawnError - the spawn rejection, when `_spawnFailed`. */ - protected onProcessDone(_proc: BashProcess, _stderr: string): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } /* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml new file mode 100644 index 0000000000..f8100bf8a0 --- /dev/null +++ b/packages/bash/pwsh-sandbox/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/bash/pwsh-sandbox/README.md +README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2 +README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md new file mode 100644 index 0000000000..bd506d011f --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.md @@ -0,0 +1,34 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +English | [中文](README.zh.md) + +Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt. + +The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy). + +## Behavior + +- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`. +- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`. + +## Model Experience + +### Confinement works, denial surfaces as command failure + +#### What the model sees + +The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. + +#### Token effect + +No model-visible text beyond the command's stderr and the tool layer's standard denial surface. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + +## Known Limitations and Deferred Work + +- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. +- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. +- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md new file mode 100644 index 0000000000..e9aa380302 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -0,0 +1,34 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +[English](README.md) | 中文 + +沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 + +执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。 + +## 行为 + +- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`。 +- 受限模式(`read-only`、`workspace-write`):pwsh argv 由 `ctx.sandbox.confine()` 包装;runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`。 + +## 模型体验 + +### 隔离生效,拒绝以命令失败呈现 + +#### 模型看到什么 + +受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 + +#### Token 影响 + +除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。 + +#### KV Cache 影响 + +无直接影响;拒绝呈现面属于工具层。 + +## 已知限制与后续工作 + +- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 +- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json new file mode 100644 index 0000000000..6f2a87fc58 --- /dev/null +++ b/packages/bash/pwsh-sandbox/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-sandbox", + "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", + "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" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-pwsh-local": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-sandbox/src/helpers.ts b/packages/bash/pwsh-sandbox/src/helpers.ts new file mode 100644 index 0000000000..44524a3d87 --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/helpers.ts @@ -0,0 +1,120 @@ +/** + * Internal sandbox-result classification helpers — deliberate call-for-call + * mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of + * the bash consumer shares the identical classification dialect). + * + * @module @deepseek-ai/dsh-pwsh-sandbox/helpers + */ + +/* jscpd:ignore-start */ +import { accessSync, constants, statSync } from 'node:fs' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Node-local spawn codes proven to identify executable resolution or permission failure. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT']) + +/** Whether the caller-owned spawn cwd can be entered. */ +function isUsableWorkdir(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** + * Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance + * after independently ruling out the caller-owned cwd. A supplied error path + * must exactly identify the runner; without one, the syscall must. With a + * usable cwd, these codes describe resolution or execute permission for that + * argv[0] or its shebang interpreter. + * The workdir is checked at classification time, not atomically with spawn; + * concurrent path replacement may change attribution but cannot permit an + * unconfined execution. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the caller-owned spawn cwd, checked independently for usability. + * @returns whether the rejection has executable-specific runner evidence. + */ +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, +): boolean { + if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false + if (typeof error !== 'object' || error === null) return false + const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown } + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false + if (typeof syscall !== 'string') return false + const exactSyscall = `spawn ${runnerProgram}` + if (path === undefined) return syscall === exactSyscall + if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false + return syscall === 'spawn' || syscall === exactSyscall +} + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string +} + +/** + * Classify a failed run against the selected backend's denial dialect. + * @param result - settled foreground run. + * @param signatures - case-insensitive denial substrings from the active wrap. + * @returns whether the failed run matches that denial dialect. + */ +export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean { + return matchesSignature(result.exitCode, result.stderr.text, signatures) +} + +/** + * Classify one settled process against the selected backend's structured + * runner-failure rules. Each rule requires a nonzero exit, its optional + * exit-code gate, and a fatal signature on one stderr line after exact + * informational lines are excluded. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text, left unchanged. + * @param rules - structured runner-failure rules from the active wrap. + * @returns the first matching fatal line, or undefined when evidence is insufficient. + */ +export function classifyRunnerFailure( + exitCode: number | null, + stderr: string, + rules: readonly RunnerFailureRule[], +): RunnerFailureMatch | undefined { + if (exitCode === null || exitCode === 0) return undefined + const lines = stderr.split(/\r?\n/) + for (const rule of rules) { + if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue + const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase())) + // An empty or whitespace-only substring is not meaningful runner evidence. + // Ignore it while keeping any valid signatures beside it active. + const fatalSignatures = rule.fatalSignatures + .filter(signature => signature.trim().length > 0) + .map(signature => signature.toLowerCase()) + for (const line of lines) { + const lowered = line.toLowerCase() + if (informationalLines.has(lowered)) continue + if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line } + } + } + return undefined +} + +/** + * Match a non-zero exit against case-insensitive stderr signatures. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text. + * @param signatures - substrings identifying the selected backend's dialect. + * @returns whether this is a non-zero exit whose stderr matches a signature. + */ +export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean { + if (exitCode === null || exitCode === 0) return false + const lowered = stderr.toLowerCase() + return signatures.some(signature => lowered.includes(signature.toLowerCase())) +} +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts new file mode 100644 index 0000000000..7a930b7f0e --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -0,0 +1,189 @@ +/** + * Sandbox-consuming PowerShell executor — the pwsh twin of + * `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through + * `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner + * chain), inherits local process mechanics, and reports the selected mode, + * enforcement, and denial facts. Positive runner-launch evidence means the + * command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while + * background processes carry `runnerFailed`; other spawn rejections retain + * local-executor semantics. The tool layer owns the escalation approval flow + * through `ctx.approval`; this executor reports the sandbox facts the tool + * renders. + * @module @deepseek-ai/dsh-pwsh-sandbox + */ + +import { Context } from 'cordis' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { + ConfinedArgv, + ConfinedSandboxMode, + RunnerFailureRule, + SandboxEnforcement, + SandboxExecutionPolicy, + SandboxMode, + SandboxPolicy, +} from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' + +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig + +/** + * Registers as `ctx.bash` in place of the local pwsh executor and requires a + * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the + * sandbox denial rendering and escalation surface (see the + * pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's + * resolved policy; direct calls fall back to deployment policy. + * `result.sandbox` reports the mode, enforcement, and denial facts the tool + * renders. + */ +/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */ +export class SandboxPwshExecutor extends PwshLocalExecutor { + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] + + // No own Config: the sandbox default (mode + workspaceRoot) moved to + // ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config + // verbatim (the config catalog walks the inherited static). + + private readonly mode: SandboxMode + /** + * Per-process confinement facts retained until settlement. Providers may + * vary enforcement and diagnostic dialect between overlapping calls, so a + * shared latest-wrap value would classify a process against the wrong facts. + * Unconfined processes have no entry. + */ + private readonly processFacts = new Map<BashProcess, { + mode: ConfinedSandboxMode + enforcement: SandboxEnforcement + denialSignatures: readonly string[] + runnerFailureRules: readonly RunnerFailureRule[] + runnerProgram: string | undefined + workdir: string + }>() + + constructor(ctx: Context, config: Config) { + super(ctx, config) + // The default mode is the capability fact used for schema advertisement; + // actual tool executions carry their resolved per-call policy. + this.mode = ctx.sandboxPolicy.defaultMode + } + + /** The configured default mode — the capability fact the tool layer reads. */ + override get sandboxMode(): SandboxMode { + return this.mode + } + + /** + * Stamp a complete per-call policy onto the spec. Tool calls supply the + * calling session's resolved mode and root; lower-level callers fall back to + * the deployment policy. + */ + override resolve(request: BashExecRequest): BashExecSpec { + return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() } + } + + override async run(spec: BashExecSpec): Promise<BashRunResult> { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') { + const result = await super.run(spec) + return { ...result, sandbox: { mode, denied: false } } + } + const confined = this.confine(spec, { ...policy, mode }) + let result: BashRunResult + try { + result = await this.runArgv(spec, confined.argv) + } catch (error) { + // An upstream abort remains cancellation even when it prevents spawn. + if (spec.signal?.aborted === true) spec.signal.throwIfAborted() + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + // Runner failure outranks denial because the command did not run. Carry + // the matched fatal line, not an informational line that preceded it. + const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) + if (runnerFailure !== undefined) { + throw new SandboxUnavailableError(mode, runnerFailure.detail) + } + return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } } + } + + override start(spec: BashExecSpec): BashProcess { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') return super.start(spec) + // Once startArgv returns, install facts synchronously; promise settlement + // cannot run before start() returns. + const confined = this.confine(spec, { ...policy, mode }) + let proc: BashProcess + try { + proc = this.startArgv(spec, confined.argv) + } catch (error) { + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + workdir: spec.workdir, + }) + return proc + } + + /** + * Stamp per-process sandbox facts before `done` settles. Full-access + * processes have no facts; signal deaths are not denials. + */ + protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void { + const facts = this.processFacts.get(proc) + if (facts !== undefined) { + this.processFacts.delete(proc) + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. + const runnerFailed = spawnFailed + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined + proc.sandbox = { + mode: facts.mode, + denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), + enforcement: facts.enforcement, + ...(runnerFailed ? { runnerFailed } : {}), + } + } + super.onProcessDone(proc, stderr, spawnFailed, spawnError) + } + + /** + * Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors + * propagate unchanged; the returned argv is handed directly to the local + * executor's subprocess path. + * @param spec - resolved execution spec whose pwsh argv is confined. + * @param policy - resolved confined execution policy. + * @returns the provider's exact argv and settlement-classification facts. + */ + private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv { + return this.ctx.sandbox.confine(this.argv(spec), policy) + } +} +/* jscpd:ignore-end */ + +export default SandboxPwshExecutor diff --git a/packages/bash/pwsh-sandbox/src/invariant.ts b/packages/bash/pwsh-sandbox/src/invariant.ts new file mode 100644 index 0000000000..6afda519ff --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`. + * @module @deepseek-ai/dsh-pwsh-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-sandbox-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond contracts enforced at its owning seams. + */ +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/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts new file mode 100644 index 0000000000..bb6f4f25e4 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -0,0 +1,111 @@ +/** + * Real-backend end-to-end: LocalSandboxProvider (win32 chain → the + * windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with + * REAL pwsh spawns confined through the runner — the debug-instance + * verification of both modes: read-only denies every write (not even NUL), + * workspace-write allows the workspace and temp while denying escape writes, + * and denial/classification facts ride the settled result. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +} + +describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let executor!: SandboxPwshExecutor + + beforeAll(async () => { + // The escape probe must live OUTSIDE every legitimately granted tree: the + // provider's workspace-write grants the workspace plus the REAL temp dir + // (the 'backend-defined temp area', same as Landlock granting /tmp), so a + // scratch dir under temp would inherit the grant and the probe would be a + // false pass. A mkdtemp under the profile is removed by afterAll. + scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxPwshExecutor, {}) + executor = ctx.bash as SandboxPwshExecutor + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => { + const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: DENIED') + expect(result.stdout.text).toContain('TEMP-WRITE: DENIED') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false) + // A self-caught denial keeps the command exit 0: no denial fact. + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + + // A raw failing write must classify as a denial of the ACL dialect. + const denied = await executor.run(executor.resolve({ + command: `Set-Content -Path '${escapeFile}' -Value x`, + sandboxPolicy: policy, + })) + expect(denied.exitCode).not.toBe(0) + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 60_000) + + it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { + const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: OK') + expect(result.stdout.text).toContain('TEMP-WRITE: OK') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) + expect(existsSync(escapeFile)).toBe(false) + expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + }, 60_000) +}) diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts new file mode 100644 index 0000000000..d710801004 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -0,0 +1,326 @@ +/** + * Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service + * makes wrapping, policy hand-off, fail-closed propagation, and fact stamping + * deterministic; real-provider integration lives in `tests/acl.e2e.ts`. + * Requires pwsh for the integration block (skips without it — same gate as + * pwsh-local's suites); the helpers block is pure and always runs. + */ + +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { Context, Service } from 'cordis' +import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' +import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts' + +// The same probe pwsh-local's suites and the vitest coverage exemption use: +// spawnSync never throws on a missing binary (it reports status null), and +// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth. +function pwshAvailable(): boolean { + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +} + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-')) + +/** One recorded provider call: the argv handed over and the policy it rode with. */ +interface ConfineCall { + argv: string[] + policy: SandboxPolicy +} + +/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */ +const passthrough = (argv: readonly string[]): ConfinedArgv => + ({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] }) + +/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */ +function throwingSubprocessService(error: unknown): new (ctx: Context) => Service { + return class extends Service { + constructor(ctx: Context) { + super(ctx, 'subprocess') + } + + spawn(): never { + throw error + } + } +} + +async function setup( + behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough, + subprocess: new (ctx: Context) => Service = LocalSubprocessService, +): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> { + const calls: ConfineCall[] = [] + class FakeSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { + calls.push({ argv: [...argv], policy }) + return behavior(argv, policy) + } + } + const ctx = new Context() + await ctx.plugin(FakeSandboxProvider) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir }) + await ctx.plugin(subprocess) + if (ctx.subprocess instanceof LocalSubprocessService) { + ctx.subprocess.internals = { spillDir } + } + await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 }) + return { executor: ctx.bash as SandboxPwshExecutor, calls } +} + +describe('helpers (pure)', () => { + const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-')) + afterAll(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + describe('isRunnerSpawnFailure', () => { + const absolute = process.execPath + const bare = 'node' + const relative = './sandbox-runner' + + it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => { + for (const runnerProgram of [absolute, bare, relative]) { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true) + } + }) + + it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false) + expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false) + // An existing FILE (not a directory) workdir is unusable without throwing. + const fileWorkdir = join(workdir, 'a-file') + writeFileSync(fileWorkdir, 'x') + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false) + }) + }) + + describe('classifyRunnerFailure', () => { + const rules: readonly RunnerFailureRule[] = [{ + allowedExitCodes: [127], + fatalSignatures: ['fake-runner: '], + informationalLines: ['fake-runner: partial enforcement'], + }] + + it('matches a fatal signature on a gated exit code, skipping informational lines', () => { + expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules)) + .toEqual({ detail: 'fake-runner: profile refused' }) + }) + + it('rejects zero/null exits, gate mismatches, and empty signatures', () => { + expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined() + }) + + it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => { + const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }] + expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules)) + .toEqual({ detail: 'windows-acl-run: missing --workspace' }) + }) + }) + + describe('matchesSignature', () => { + it('matches non-zero exits case-insensitively, never zero or signal exits', () => { + expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true) + expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true) + expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false) + expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false) + expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false) + }) + }) +}) + +describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => { + // Denial device for the POSIX classification cases: a mode-0555 directory + // INSIDE a temp scratch tree (the same device as bash-sandbox's suites) — + // unit tests never attempt writes outside the system temp directory. On + // win32 there is no POSIX mode denial; the real-sandbox denial coverage + // lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths. + const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-')) + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555) + const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')` + + afterAll(() => { + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755) + rmSync(readOnlyDir, { recursive: true, force: true }) + rmSync(spillDir, { recursive: true, force: true }) + }) + + const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' } + + it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(1) + const call = calls[0] + expect(call?.policy).toEqual(RO) + // The confined argv is the pwsh invocation, ready for a runner prefix. + expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u) + expect(call?.argv).toContain('-NonInteractive') + expect(call?.argv.at(-1)).toContain('echo wrapped') + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => { + const { executor, calls } = await setup() + expect(executor.sandboxMode).toBe('workspace-write') + const result = await executor.run(executor.resolve({ command: 'echo fallback' })) + expect(result.exitCode).toBe(0) + expect(calls[0]?.policy.mode).toBe('workspace-write') + }, 30_000) + + it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(0) + expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) + }, 30_000) + + it('an aborted caller signal outranks runner-spawn attribution', async () => { + const controller = new AbortController() + controller.abort('caller-cancel') + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [], + })) + await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal }))) + .rejects.toThrow('caller-cancel') + }, 30_000) + + // POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the + // real-sandbox denial classification is covered by tests/acl.e2e.ts + // (the ACL runner denies scratch paths — unit tests never leave temp). + it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => { + const { executor } = await setup() + const result = await executor.run(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + expect(result.exitCode).not.toBe(0) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => { + const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow('sync-emfile') + }, 30_000) + + it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => { + const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow('sync-emfile-start') + }, 30_000) + + it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => { + const { executor } = await setup(() => ({ + argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('background confined runs stamp clean facts at settlement', async () => { + const { executor } = await setup() + const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO })) + await clean.done + expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + // POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial + // coverage lives in tests/acl.e2e.ts. + it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => { + const { executor } = await setup() + const denied = executor.start(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + await denied.done + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('background spawn rejections settle as runnerFailed facts', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO })) + await proc.done + expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true }) + // The failure note surfaces through the read path. + const read = proc.readOutput() + expect(read.delta).toContain('spawn failed') + }, 30_000) + + it('danger-full-access background runs bypass confine and carry no facts', async () => { + const { executor, calls } = await setup() + const proc = executor.start(executor.resolve({ + command: 'echo full-bg', + sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' }, + })) + await proc.done + expect(calls).toHaveLength(0) + expect(proc.sandbox).toBeUndefined() + }, 30_000) +}) diff --git a/packages/bash/pwsh-sandbox/tsconfig.json b/packages/bash/pwsh-sandbox/tsconfig.json new file mode 100644 index 0000000000..55eac06470 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../bash/pwsh-local" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index e272ee2993..b6e043fd84 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: 7d8ee5fb69b71d8e8707d3e4ed07ebdda99f799f -README.zh.md: 40984bbc36be4b5809e6ee4db21e52d842f50cdb +README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8 +README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 7d8ee5fb69..3fd5a53946 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). +The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, the sandbox denial rendering with the same-turn `sandbox_permissions` escalation surface, and the bash marker/truncation rendering story (a clean exit produces no marker). Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). @@ -21,6 +21,8 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero | `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | | `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | | `run_in_background` | boolean | Return a task id immediately; no timeout applies. | +| `sandbox_permissions` | string enum | Advertised only when a sandboxing executor is mounted (`ctx.bash.sandboxMode` defined). The wider sandbox mode for a one-shot retry of a command the sandbox just denied — the narrowest wider mode that suffices, requiring `justification` and user approval through `ctx.approval` BEFORE execution. A non-widening or unapprovable request fails closed without running anything. | +| `justification` | string | Required with `sandbox_permissions`: one sentence for the user explaining why this exact command needs the wider access. | `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. @@ -28,9 +30,9 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables. -Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. +Result text contains stdout, an optional `[stderr]` section, then applicable truncation, sandbox-denial (with the same-turn escalation hint when the composition advertises escalation), timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. -The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text. +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text. When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. @@ -78,7 +80,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict #### What the model sees -The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`. +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[sandbox: file access denied under <mode> mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`. #### Token effect @@ -106,7 +108,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. +Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. #### Token effect @@ -118,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). -- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only. +- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. +- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. -- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here. +- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 40984bbc36..c06b4354b6 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 +注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约定是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 @@ -21,6 +21,8 @@ | `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | | `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | | `run_in_background` | boolean | 立即返回 task id;不适用超时。 | +| `sandbox_permissions` | string enum | 仅当已挂载 sandbox 执行器时才会公开(`ctx.bash.sandboxMode` 已定义)。用于对刚被 sandbox 拒绝的命令做一次性重试的更宽 sandbox 模式——取刚好足够的最窄更宽模式,要求 `justification` 并在执行**之前**经 `ctx.approval` 获得用户批准。未拓宽或无法获批的请求 fail-closed,不运行任何内容。 | +| `justification` | string | 必须与 `sandbox_permissions` 一同提供:用一句话向用户解释为何正是这条命令需要更宽的访问。 | `command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 @@ -28,9 +30,9 @@ 每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。 -结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、sandbox 拒绝(组合公开升级能力时带同轮次升级提示)、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 -规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。 +规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。 当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 @@ -78,7 +80,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### What the model sees -渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。 +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[sandbox: file access denied under <mode> mode]` 加升级提示 `[sandbox: escalation available — …]`(仅当组合公开升级能力时)、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。 #### Token effect @@ -106,7 +108,7 @@ ack 是固定短行;任务输出按读取有界。 #### What the model sees -校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 +校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享的升级失败(非严格更宽、无审批服务、无 agent 可路由、无审批通道、用户拒绝、已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 #### Token effect @@ -118,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 -- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端仅限 Linux/macOS。 +- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 +- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 -- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 +- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 0c25317faa..042166b493 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -30,9 +30,12 @@ "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -46,12 +49,15 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 8f30de893c..9ef6650748 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,13 +4,17 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: - * foreground and `run_in_background` execution (background handles register - * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment - * through the shared `bash-env` registry, and the bash marker/truncation - * rendering story. UI presentation mirrors the bash tool's too: a completed - * foreground call is a terminal card with the parsed exit-status pill, using - * the shared exit-status parse from `@deepseek-ai/dsh-bash`. + * Behavior mirrors `dsh-tool-bash` call-for-call: foreground and + * `run_in_background` execution (background handles register with the + * generic `ctx.tasks` runtime), the managed `DSH_*` environment through the + * shared `bash-env` registry, the per-call sandbox policy resolution (the + * calling session's mode and cwd travel to the confining executor), the + * sandbox-denial rendering with the same-turn escalation surface + * (`sandbox_permissions` + `justification` resolved through + * `ctx.approval`), and the bash marker/truncation rendering story. UI + * presentation mirrors the bash tool's too: a completed foreground call is + * a terminal card with the parsed exit-status pill, using the shared + * exit-status parse from `@deepseek-ai/dsh-bash`. * * @module @deepseek-ai/dsh-tool-pwsh */ @@ -19,16 +23,21 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' -import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-bash-env' +import type {} from '@deepseek-ai/dsh-user-approval' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { parseExitStatus } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' +import type { RenderablePwshResult } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -57,6 +66,8 @@ interface PwshToolArgs { timeoutMs?: number workdir?: string run_in_background?: boolean + sandbox_permissions?: string + justification?: string } /** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ @@ -69,6 +80,7 @@ interface PwshForegroundResult { timeoutMs: number stdout: { text: string; truncated: boolean; spillPath?: string } stderr: { text: string; truncated: boolean; spillPath?: string } + sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean } } /* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ @@ -82,21 +94,54 @@ function validatePwshArgs(args: PwshToolArgs): void { if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) } + // The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is + // the shared rule both enforcing families validate identically. + validateEscalationArgs(args.sandbox_permissions, args.justification) } /* jscpd:ignore-end */ -function pwshDescription(backgroundEnabled: boolean): string { +function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string { const background = backgroundEnabled ? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.' : 'Background execution is not available; long-running commands must finish within the timeout.' - return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + const base = 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — ' + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment ' + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. ' + 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. ' + + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. ' + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background + if (escalationModes.length === 0) return base + // The CLM and named-pipe contracts below are Windows-restricted-token + // behavior, but the gate is 'any confining executor is mounted' + // (escalationModes non-empty). The conflation is safe today because every + // shipped composition pairing tool-pwsh with a confining executor is + // win32-only; a future POSIX pwsh-sandbox composition must gate both + // sentences on the platform instead (tracked in the pwsh-tool-and-executor + // Agent Note). + return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' + + 'In the same modes, programs cannot open named pipes, so a command that captures another ' + + 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default ' + + '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns ' + + 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: ' + + 'do not retry the command another way — escalate the exact command once or restructure it to ' + + 'avoid capturing output. ' + + 'Attempting a command the sandbox may deny is safe and expected: run it and read the ' + + 'marker rather than assuming the denial. When a command is denied and a wider mode would let it ' + + 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry ' + + 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) ' + + 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the ' + + 'approval prompt raised by that retry is how the user consents. If the session states approval ' + + 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. ' + + 'Never escalate speculatively: ground the request in a real denial — normally the one this command ' + + 'just hit; escalating up front is fine only when this session already denied the same access. ' + + 'A rejected escalation is final for that command — stop and explain, never work around ' + + 'it — but it does not forbid attempting or escalating other commands later.' } /** @@ -129,6 +174,14 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { /* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */ stdout: output(result.stdout), stderr: output(result.stderr), + ...result.sandbox !== undefined ? { + sandbox: { + mode: result.sandbox.mode, + denied: result.sandbox.denied, + ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}, + ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}, + }, + } : {}, } } @@ -139,8 +192,55 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const /* jscpd:ignore-end */ +/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const defaultMode = ctx.bash.sandboxMode + const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS + const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && sandboxPolicy === undefined) { + throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing') + } + /* jscpd:ignore-end */ + /** Resolve the complete standing policy for this call when a confining executor is mounted. */ + const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => + sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) + + /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */ + /** + * Resolve a sandbox-escalation request through `ctx.approval` BEFORE + * anything executes, delegating the shared fail-closed sequence (strict + * widening, channel resolution, outcome mapping) to + * {@link approveEscalation}. This tool contributes only the composition + * guard (the fields are unadvertised without a sandboxing executor, yet + * schema validation checks advertised keys only, so an unadvertised + * `sandbox_permissions` still reaches execute) and the approval + * ingredients. The shared policy resolver is required whenever the + * executor advertises confinement, so a split composition fails at + * tool-plugin load. + */ + const approvePwshEscalation = ( + mode: string, + justification: string, + exec: ToolExecution, + standingPolicy: SandboxExecutionPolicy | undefined, + ): Promise<SandboxMode> => { + if (escalationModes.length === 0) { + throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + } + const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode + return approveEscalation( + { requestedMode: mode, justification, effectiveMode, subject: 'command' }, + { + approver: ctx.get('approval'), + agent: exec.agent, + callId: exec.callId, + toolName: 'pwsh', + signal: exec.signal, + }, + ) + } + /* jscpd:ignore-end */ ctx.systemPrompt.section({ name: 'tool:pwsh', @@ -151,7 +251,8 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.tools.register(defineTool({ name: 'pwsh', - description: pwshDescription(backgroundEnabled), + description: pwshDescription(backgroundEnabled, escalationModes), + /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */ parameters: { command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, description: { @@ -166,7 +267,19 @@ export function apply(ctx: Context, config: Config = {}): void { ...backgroundEnabled ? { run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' }, } : {}, + ...escalationModes.length > 0 ? { + sandbox_permissions: { + type: 'string' as const, + enum: [...escalationModes], + description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.', + }, + justification: { + type: 'string' as const, + description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.', + }, + } : {}, }, + /* jscpd:ignore-end */ output: { // The foreground result wire shape mirrors dsh-tool-bash's by contract — // consumers of one must accept the other (see the pwsh-tool-and-executor @@ -209,6 +322,16 @@ export function apply(ctx: Context, config: Config = {}): void { spillPath: { type: 'string' }, }, }, + sandbox: { + type: 'object', + additionalProperties: false, + properties: { + mode: { type: 'string', required: true }, + denied: { type: 'boolean', required: true }, + enforcement: { type: 'string' }, + runnerFailed: { type: 'boolean' }, + }, + }, }, }, ], @@ -218,18 +341,27 @@ export function apply(ctx: Context, config: Config = {}): void { type: 'text', text: value.kind === 'background' ? `started background task ${value.taskId}` - : renderPwshResult(value), + : renderPwshResult(value as RenderablePwshResult, escalationModes), }], }, /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ async execute(args: PwshToolArgs, exec) { validatePwshArgs(args) + // Description is display metadata; workdir defaults to the caller's session. + const standingPolicy = resolveSandboxPolicy(exec) + const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined + ? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy) + : undefined + const policy = approvedMode === undefined + ? standingPolicy + : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv: ctx.bashEnv.collect(exec), + ...policy !== undefined ? { sandboxPolicy: policy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. @@ -241,15 +373,11 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } // The caller owns cancellation until ctx.tasks commits detached ownership. - /* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort; - pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts - already-aborted signals first, so this mirror-only guard has no reachable trigger. */ if (exec.signal.aborted) { const error = new HarnessError('tool call aborted', TOOL_ABORTED) error.name = 'AbortError' throw error } - /* v8 ignore end */ // Task preflight finishes before the starter can spawn a process. const id = tasks.start({ kind: 'pwsh', @@ -260,7 +388,7 @@ export function apply(ctx: Context, config: Config = {}): void { return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), - readOutput: () => renderPwshProcessRead(proc.readOutput()), + readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes), } }, }) diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts index 42f4bc696c..52616a8e68 100644 --- a/packages/bash/tool-pwsh/src/render.ts +++ b/packages/bash/tool-pwsh/src/render.ts @@ -1,17 +1,20 @@ /** * Model-facing result rendering for the pwsh tool — the PowerShell twin of - * `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked - * stderr section, truncation notices with spill paths, then exit-status - * markers. Non-zero exits are reported, not errored — the model decides how to - * react; only infrastructure failures (spawn errors, aborts) surface as - * isError results. + * `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox + * denial/runner-failure markers (with the same-turn escalation hint), and + * truncation notices with spill paths, then exit-status markers. Non-zero + * exits are reported, not errored — the model decides how to react; only + * infrastructure failures (spawn errors, aborts) surface as isError + * results. * * @module @deepseek-ai/dsh-tool-pwsh/render */ -import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { BashProcessRead, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' -/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (Agent Note). */ /** Append the truncation notice (with the full-output spill path) to a stream's text. */ function streamText(output: CollectedOutput): string { @@ -27,6 +30,7 @@ export interface RenderablePwshResult { timeoutMs: number stdout: CollectedOutput stderr: CollectedOutput + sandbox?: BashSandboxInfo } /** @@ -34,9 +38,15 @@ export interface RenderablePwshResult { * stderr section, then exit-status markers, matching the bash tool's story — * a clean exit (0, no signal) produces no marker. * @param result - the completed foreground run from the executor. + * @param escalationModes - the escalation targets this composition advertises; + * non-empty adds the same-turn escalation hint after a denial marker + * (default `[]`: no hint). * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. */ -export function renderPwshResult(result: RenderablePwshResult): string { +export function renderPwshResult( + result: RenderablePwshResult, + escalationModes: readonly SandboxMode[] = [], +): string { const out = streamText(result.stdout) const err = streamText(result.stderr) @@ -49,6 +59,14 @@ export function renderPwshResult(result: RenderablePwshResult): string { if (body.length === 0) body = '(no output)' const markers: string[] = [] + // Keep the exit marker last because parseExitStatus anchors there. + if (result.sandbox?.denied) { + markers.push(sandboxDenialMarker(result.sandbox.mode)) + // Hint only when the composition exposes escalation, before the final exit marker. + if (escalationModes.length > 0) { + markers.push(escalationHintMarker('command')) + } + } // A command may trap the termination and exit 0 after timeout; still report interruption. if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) if (result.signal !== null) { @@ -67,14 +85,28 @@ export function renderPwshResult(result: RenderablePwshResult): string { * sees: the incremental delta, plus the lossy-read notice (with full-stream * spill paths) when in-memory truncation dropped unread bytes. * @param read - one incremental read from the process handle. - * @returns the delta text with any loss notice appended. + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the delta text with any loss or sandbox notice appended. */ -export function renderPwshProcessRead(read: BashProcessRead): string { +export function renderPwshProcessRead( + read: BashProcessRead, + sandbox?: BashSandboxInfo, + escalationModes: readonly SandboxMode[] = [], +): string { const notices: string[] = [] if (read.lossy) { const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) } + if (sandbox?.runnerFailed) { + notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) + } else if (sandbox?.denied) { + notices.push(sandboxDenialMarker(sandbox.mode)) + if (escalationModes.length > 0) { + notices.push(escalationHintMarker('command')) + } + } if (notices.length === 0) return read.delta return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` } diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 71e3124e7e..91ad796ce5 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -5,13 +5,14 @@ * text, truncation, timeout, abort, nonzero exits, background handles — so * these tests verify the schema, argument validation, workdir derivation, * managed `DSH_*` collection, abort translation, canonical result projection, - * rendering, background task wiring, and the UI presenters. Real-pwsh behavior + * sandbox denial rendering with the escalation surface, rendering, + * background task wiring, and the UI presenters. Real-pwsh behavior * is pinned separately in integration.spec.ts. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' @@ -22,8 +23,11 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import type { BashProcessRead } from '@deepseek-ai/dsh-bash' @@ -150,9 +154,106 @@ async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome return { ctx, bash } } +/** + * A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve + * the calling session's standing policy and stamp it on the request, exactly + * like the bash tool — the per-session sandbox-policy regression surface. + * Records each confined mode and returns scriptable sandbox facts so the + * escalation and rendering surfaces are testable without a real backend. + */ +class ConfiningFakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + modes: Array<string | undefined> = [] + + override get sandboxMode() { + return 'read-only' as const + } + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(spec: BashExecSpec): Promise<BashRunResult> { + this.modes.push(spec.sandboxPolicy?.mode) + return runResult('ok\n', { + sandbox: { + mode: spec.sandboxPolicy?.mode ?? 'read-only', + denied: false, + ...spec.command === 'without optional sandbox facts' + ? {} + : { enforcement: 'full' as const, runnerFailed: false }, + }, + }) + } + + override start(spec: BashExecSpec): BashProcess { + this.modes.push(spec.sandboxPolicy?.mode) + return fakeProcess() + } +} + +/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool (+ optional approval). */ +async function setupSandboxed(withApproval = false) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(SandboxPolicyService, {}) + await ctx.plugin(ConfiningFakeBash) + if (withApproval) await ctx.plugin(ApprovalService) + await ctx.plugin(ToolPwsh) + const bash = ctx.bash as ConfiningFakeBash + return { ctx, bash } +} + +/** + * Build a fake {@link Agent} whose session log carries the sandbox-policy + * mode-override event the escalation flow evaluates against, with an + * appendable log (the approval service records decisions through + * `session.append`). + */ +function sandboxAgent( + mode?: 'read-only' | 'workspace-write' | 'danger-full-access', + ctx?: Context, + onAppend?: (type: string) => void, +): Agent { + const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }] + if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) + const id = SessionId('sandbox-session') + return { + id, + ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, + session: { + id, + header: { version: 0, id, createdAt: 0 }, + events, + append: (type: string, data: Record<string, unknown>) => { + const event = { type, data } + events.push(event) + onAppend?.(type) + return event + }, + }, + } as unknown as Agent +} + /** * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + * The fake session carries an empty event log (the sandbox-policy resolver + * folds the log for mode overrides, mirroring a real session). */ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const scopeFiber = ctx.plugin(() => {}) @@ -160,7 +261,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 }, events: [] }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -397,6 +498,203 @@ describe('execution through the bash seam', () => { }) }) +describe('per-call sandbox policy resolution', () => { + it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => { + const { ctx, bash } = await setupSandboxed() + const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) + const agent = registerFakeAgent(ctx, 'policy-session') + Object.assign(agent.session.header, { cwd: sessionCwd }) + const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) + expect(result.isError).toBe(false) + // The policy's workspace root is the session cwd canonicalized by the + // policy service (realpath + resolve), NEVER the web server's launch dir; + // the calling session's identity rides along for backend per-session state. + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(sessionCwd)), + sessionId: 'policy-session', + }) + }) + + it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => { + const { ctx, bash } = await setupSandboxed() + await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(process.cwd())), + }) + + // The base FakeBash advertises no sandboxMode, so the tool must not stamp + // any policy (the executor defaulting stays the executor's own). + const plain = await setup() + await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy') + }) + + it('fails load when a confining executor has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(ConfiningFakeBash) + await expect(ctx.plugin(ToolPwsh)).rejects.toThrow( + 'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing', + ) + }) +}) + +describe('sandbox escalation through ctx.approval', () => { + const escalate = { + command: 'Write-Output ok', + description: 'test escalation', + sandbox_permissions: 'workspace-write', + justification: 'the command needs workspace writes', + } + + it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => { + const { ctx } = await setupSandboxed() + const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! + const properties = schema.parameters.properties as Record<string, { enum?: string[] }> + expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) + expect(schema.description).toContain('approval prompt') + expect(schema.description).toContain('ConstrainedLanguage') + expect(schema.description).toContain('named pipes') + expect(schema.description).toContain('fails with EPERM') + + for (const args of [ + { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' }, + { command: 'Write-Output ok', description: 'd', justification: 'why' }, + { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' }, + ]) { + expect((await call(ctx, 'pwsh', args)).isError).toBe(true) + } + }) + + it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => { + const { ctx } = await setup() + const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! + expect(schema.description).not.toContain('ConstrainedLanguage') + expect(schema.description).not.toContain('named pipes') + expect(schema.description).not.toContain('sandbox_permissions') + expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions') + }) + + it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => { + const plain = await setup() + expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition') + + const { ctx } = await setupSandboxed(true) + const prompted = vi.fn() + ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') }) + const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write')) + expect(text(result)).toContain('not strictly wider') + expect(prompted).not.toHaveBeenCalled() + + const malformed = sandboxAgent() + ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + type: 'sandbox/mode', + data: { mode: 'unknown-mode' }, + }) + expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider') + }) + + it('fails closed when approval cannot be routed', async () => { + const withoutService = await setupSandboxed() + expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service') + + const withService = await setupSandboxed(true) + expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route') + expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel') + }) + + it.each([ + ['rejected', 'user rejected'], + ['cancelled', 'was cancelled'], + ] as const)('maps an approval %s to its distinct failure', async (outcome, message) => { + const { ctx, bash } = await setupSandboxed(true) + ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome)) + const result = await call(ctx, 'pwsh', escalate, sandboxAgent()) + expect(text(result)).toContain(message) + expect(bash.modes).toEqual([]) + }) + + it('runs a granted foreground or background call under the approved mode', async () => { + const { ctx, bash } = await setupSandboxed(true) + ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once')) + const agent = sandboxAgent(undefined, ctx) + ctx.agents.register(agent) + const foreground = await ctx.tools.execute({ + callId: CallId('sandbox-signal'), + name: 'pwsh', + arguments: escalate, + agent, + signal: new AbortController().signal, + }) + expect(foreground.isError).toBe(false) + const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent) + expect(text(background)).toBe('started background task pwsh-1') + expect(bash.modes).toEqual(['workspace-write', 'workspace-write']) + }) + + it('does not publish detached work when cancellation follows the escalation grant', async () => { + const { ctx, bash } = await setupSandboxed(true) + const controller = new AbortController() + const agent = sandboxAgent(undefined, ctx, (type) => { + if (type === 'approval/decided') controller.abort() + }) + ctx.agents.register(agent) + ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once')) + const start = vi.spyOn(bash, 'start') + + const result = await ctx.tools.execute({ + callId: CallId('cancelled-escalation-background'), + name: 'pwsh', + arguments: { ...escalate, run_in_background: true }, + agent, + signal: controller.signal, + }) + + expect(result.error).toEqual({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) + expect(text(result)).toBe('Error: tool call aborted') + expect(start).not.toHaveBeenCalled() + }) + + it('uses the session override for ordinary calls and evaluates widening against it', async () => { + const { ctx, bash } = await setupSandboxed(true) + const agent = sandboxAgent('workspace-write') + await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent) + ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once')) + await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent) + expect(bash.modes).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('omits sandbox facts the executor did not acquire from the canonical result', async () => { + const { ctx } = await setupSandboxed() + const result = await call(ctx, 'pwsh', { + command: 'without optional sandbox facts', + description: 'exercise optional sandbox facts', + }) + if (result.isError) throw new Error('expected foreground pwsh success') + expect(result.value).toMatchObject({ + kind: 'foreground', + sandbox: { mode: 'read-only', denied: false }, + }) + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement') + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed') + }) + + it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => { + const { ctx } = await setupSandboxed(true) + ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome) + const result = await call(ctx, 'pwsh', escalate, sandboxAgent()) + expect(text(result)).toContain('unreachable variant in EscalationOutcome') + }) +}) + describe('background execution through the task runtime', () => { it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { const { ctx } = await setupWithTasks() @@ -641,6 +939,35 @@ describe('UI presentation', () => { }) }) +describe('renderPwshResult sandbox markers', () => { + const base = { + exitCode: 0, + signal: null, + timedOut: false, + timeoutMs: 1000, + stdout: { text: 'out\n', truncated: false }, + stderr: { text: '', truncated: false }, + } + + it('a denied run reports the denial marker before the exit marker', () => { + expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } })) + .toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]') + }) + + it('hints only when the composition advertises escalation', () => { + const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } } + expect(renderPwshResult(denied, ['workspace-write'])).toBe( + 'out\n[sandbox: file access denied under read-only mode]\n' + + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' + + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]', + ) + }) + + it('a confined run without a denial adds no sandbox marker', () => { + expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n') + }) +}) + describe('renderPwshProcessRead', () => { const base: BashProcessRead = { delta: 'out\n', lossy: false } @@ -677,6 +1004,20 @@ describe('renderPwshProcessRead', () => { expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') }) + + it('appends the runner-failed notice (denial outranked)', () => { + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true })) + .toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]') + }) + + it('appends the denial marker and hints only when escalation is advertised', () => { + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true })) + .toBe('x\n[sandbox: file access denied under read-only mode]') + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write'])) + .toBe('x\n[sandbox: file access denied under read-only mode]\n' + + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' + + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') + }) }) describe('processOutcome', () => { diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 61b2c69448..d9c56260fc 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -38,6 +38,18 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../bash/bash-env" + }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../support/invariants" } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 58ae933e12..5d51b559d4 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/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/boot/app-boot/README.md -README.md: 1bbd376121ae79bf37376b51f5ac3eb405af6dfd -README.zh.md: 15263d8de9b69fc976ce328a6056e1b35e9beda5 +README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0 +README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 1bbd376121..49c75bac1b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -15,7 +15,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `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 | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — 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; a missing file also throws, because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | | `watchUserPatches(ctx, options)` | Register the named patch file 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 user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `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), 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 | @@ -27,6 +27,8 @@ Loader settlement rejects import and lifecycle failures with the failing entry a The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. +`cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. + Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. 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. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 15263d8de9..93adc52c11 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -15,7 +15,7 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | @@ -27,6 +27,8 @@ Loader 结算会在导入或生命周期失败时返回拒绝结果,并携带 Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先 dispose 部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前 dispose 整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间,处理函数保持注册并处于锁定状态:被报告的始终是第一个 rejection,后续拒绝(包括拆卸自身产生的拒绝)会被忽略,而不会变成未捕获错误、在拆卸中途杀死进程。 +`cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 + 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index e2d0ed2391..c33dc878b8 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -28,6 +28,7 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { + "@cordisjs/plugin-group": "^1.0.0", "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", @@ -43,6 +44,7 @@ } }, "devDependencies": { + "@cordisjs/plugin-group": "workspace:^", "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index c722f30f0f..256ea34299 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -14,6 +14,7 @@ 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 Group from '@cordisjs/plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import type {} from '@cordisjs/plugin-hmr' @@ -485,6 +486,12 @@ export async function mountRootInclude( patches: readonly PatchOptions[] = [], ): Promise<Entry | undefined> { ctx.loader.builtins.include = Include + // `cordis:group` alongside it: a group row is how a composition gives one + // `isolate` realm to a provider and its consumers together, and an agent + // preset living outside this workspace cannot resolve `@cordisjs/plugin-group` + // by name. Both builtins load through the ambient module pipeline, so neither + // depends on the included tree's own specifier resolution. + ctx.loader.builtins.group = Group // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 45eab9ea7d..9cbe3d1a58 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -8,9 +8,8 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import { Context } from 'cordis' import type { Include } from '@cordisjs/plugin-include' -import { Group } from '@cordisjs/plugin-loader' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -219,8 +218,10 @@ describe('loader tree replacement', () => { }) it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { + // No manual builtin registration: `boot()` supplies `cordis:group` beside + // `cordis:include`, which is what lets a composition give one `isolate` + // realm to a provider and its consumers together. const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') - ctx.loader.builtins.group = Group try { const config = (disabled: boolean) => [ '- id: parent', @@ -253,7 +254,6 @@ describe('loader tree replacement', () => { const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), }) - ctx.loader.builtins.group = Group try { const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) @@ -386,3 +386,46 @@ describe('include patches layered over one base', () => { } }) }) + +describe('shipped builtins', () => { + it('lets a booted composition share one isolate realm across a group of rows', async () => { + // The reason `boot()` registers `cordis:group`: a composition — notably an + // agent preset living outside this workspace, which cannot resolve + // `@cordisjs/plugin-group` by name — gives a provider and its consumer one + // named realm so the service stays out of the root realm while remaining + // visible to the rows that need it. + const { ctx } = await bootTree([ + '- id: realm', + ' name: cordis:group', + ' isolate:', + ' demoRealmSvc: true', + ' config:', + ' - id: provider', + ' name: ./provider.mjs', + ' - id: consumer', + ' name: ./consumer.mjs', + '', + ].join('\n'), { + 'provider.mjs': 'export const name = "provider"\n' + + 'export function apply(ctx) { ctx.effect(() => ctx.reflect.provide("demoRealmSvc", { tag: "realm" })) }\n', + 'consumer.mjs': 'export const name = "consumer"\n' + + 'export const inject = ["demoRealmSvc"]\n' + + 'export function apply(ctx) { globalThis.__REALM_SEEN__ = ctx.get("demoRealmSvc").tag }\n', + }) + try { + expect((globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__).toBe('realm') + // `provide` mints the root symbol unconditionally (cordis `reflect.ts`), + // so the name IS in the root realm — pinned here because it is the half + // that looks like the claim and is not. The claim is the other half: no + // implementation is stored under that symbol, so the root realm cannot + // resolve the service and a second composition mounting the same rows + // cannot collide with this one. + const rootKey = ctx.root[Context.isolate].demoRealmSvc + expect(rootKey).toBeDefined() + expect(ctx.reflect.store[rootKey!]).toBeUndefined() + } finally { + delete (globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__ + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index 18ddbedad3..e8a7b79612 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/group" + }, { "path": "../../../vendor/hmr" }, diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 973959478e..29786ca332 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: fb003908a262dc21edd3c9d49c972e487534f367 -README.zh.md: 13e64db6d34374fac63bf9bfd60544fc46b86f35 +README.md: 2a87b01ad4819750a58163f8c472e61ea633588e +README.zh.md: dc79895355546812aa3371487190724f169c6260 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index fb003908a2..2a87b01ad4 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. + The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. ## Model Experience @@ -17,3 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`<temp>\dsh-<hash>`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 13e64db6d3..dc79895355 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,6 +4,8 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 + 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 ## 模型体验 @@ -17,3 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`<temp>\dsh-<hash>`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c512127199..977afa8863 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -65,7 +65,7 @@ - id: agent name: '@deepseek-ai/dsh-agent' - # The transport-independent default for Agents created by front doors. + # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index c2cd151b3e..fe03bdc7e3 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -16,6 +16,7 @@ "default": "./lib/invariant.js" }, "./cordis.patch.yml": "./cordis.patch.yml", + "./windows.cordis.patch.yml": "./windows.cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -23,6 +24,7 @@ "lib/index.js", "lib/invariant.js", "cordis.patch.yml", + "windows.cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -46,6 +48,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", @@ -57,6 +60,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", @@ -87,6 +91,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 24ee2a1ba3..2da84a0931 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -13,13 +13,51 @@ import { entryListSchema } from '@cordisjs/plugin-include' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { const root = fileURLToPath(new URL('..', import.meta.url)) - const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } } + const manifest = JSON.parse( + readFileSync(resolve(root, 'package.json'), 'utf8'), + ) as { dsh?: { bundle?: { patch?: string } } } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') - const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }) + const parsed = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) expect(Array.isArray(parsed)).toBe(true) // The base layer is one insert list over the empty profile root. - const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? []) + const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap( + patch => patch.insert ?? [], + ) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) }) + + it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const parsed = yaml.load( + readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), + { schema: entryListSchema }, + ) as { + id?: string + disabled?: boolean + insert?: { id?: string; name?: string }[] + config?: { policy?: string } + }[] + const disables = parsed + .filter(patch => patch.disabled === true) + .map(patch => patch.id) + // Only the POSIX bash stack is disabled: the Windows roster confines the + // pwsh executor through the ACL runner chain, so the sandbox/policy rows, + // the permission switcher, fs-sandbox, and the approval service all stay + // enabled exactly as on POSIX — only the shell is swapped. + expect(disables).toEqual(['bash-sandbox', 'tool-bash']) + const inserted = parsed + .flatMap(patch => patch.insert ?? []) + .map(row => row.id) + expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh']) + // The patch no longer touches the permission/approval surface at all. + expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined() + }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml new file mode 100644 index 0000000000..6db6a57098 --- /dev/null +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -0,0 +1,31 @@ +# The dsh-base Windows platform layer: applied by the dsh launcher on win32 +# hosts, between the bundle layers and the user layers. Windows confines +# through the ACL restricted-token runner (the win32 chain of +# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped +# stack is the SANDBOXED PowerShell executor plus the full permission +# surface: sandbox/sandbox-policy enforce the file-effect policy, the +# permission switcher and the approval service run exactly as on POSIX, and +# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting +# dsh-fs-local alongside it would double-register ctx.fs and fail the load. +# Only the POSIX bash +# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. +# A Windows host that prefers the unconfined local pwsh executor or full +# access overrides these rows through its profile or home cordis.patch.yml. +# The bash-restore recipe must be complete: disable pwsh-sandbox and +# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor +# families register the same 'bash' service, so re-enabling the bash rows +# while pwsh-sandbox stays inserted fails loud at load on a duplicate +# registration. + +- id: bash-sandbox + disabled: true + +- id: tool-bash + disabled: true + +- insert: + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' + + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c3a66ebbf4..e4c4935a2a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -183,6 +183,11 @@ - id: ui-permission name: '@deepseek-ai/dsh-client-ui-permission' + # The agent-preset row in General settings: the default preset for + # sessions created later. Absent a roster it renders nothing. + - id: ui-agent-preset + name: '@deepseek-ai/dsh-client-ui-agent-preset' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' @@ -192,3 +197,136 @@ - id: ui-trajectory name: '@deepseek-ai/dsh-client-ui-trajectory' + +# ── the agent plane moves behind agent presets ───────────────────────────── +# +# Every row below composes what ONE agent contributes to the host registries: +# its tools, its prompt sections, its delegation backends. The base keeps them +# for the TUI, which is single-session and composes its agent process-wide; the +# Web surface disables them here and lets each session mount a preset instead. +# +# Disabling rather than deleting is deliberate: the base is shared, and a row +# absent from a surface overlay would silently reappear the day someone reorders +# the composition. + +# `bash-env` STAYS in the host plane: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# would never reach the model's shell at all. + +- id: tool-bash + disabled: true + +- id: tool-tasks + disabled: true + +- id: tasks + disabled: true + +- id: tool-fs + disabled: true + +- id: tool-fs-search + disabled: true + +- id: tool-str-replace-editor + disabled: true + +# The `skill` REGISTRY stays in the host plane. It is host+per-scope layered +# (the tools-registry shape): deployment-level providers — repository plugins, +# a host skill-local row — register into its global layer, while a preset's +# `skill-local` registers into that preset's layer, and each agent reads the +# merged catalog its scope chain selects. Only the per-agent rows move behind +# presets: the base host `skill-local` row is disabled here (presets own local +# discovery), and `tool-skill` is what a preset mounts to give its agent the +# catalog and loader at all. + +- id: skill-local + disabled: true + +- id: tool-skill + disabled: true + +# The goal SERVICE, its session driver, and the `/goal` command STAY on the +# host plane; only the model-facing tool moves. The Gateway serves the goal +# domain as Remote endpoints, and a Remote method picks its receiver Service +# from a generated descriptor — it resolves `goals` on the host, so a +# per-session realm would answer `service-unavailable` for every browser call. +# That is the `bash-env` criterion read from the other side: injection is not +# the only host relationship a Service can have. The registry is keyed by +# session, so one host instance serves every session exactly as before presets. + +- id: tool-goal + disabled: true + +- id: plan-mode + disabled: true + +- id: token-meter + disabled: true + +- id: compact-basic + disabled: true + +- id: command-compact + disabled: true + +- id: tool-result-prune + disabled: true + +# The subagent registry and its backends STAY in the host plane. `subagents` is +# a process singleton with a cross-session query surface (`listChildren`, +# `followup`) that the host api-proxy serves to the browser, and a provider +# registers under a globally unique name, so a per-session copy would both +# starve that host row and collide on the second session. What a preset +# chooses is which delegation TOOLS its agent sees, below. + +- id: tool-subagent-control + disabled: true + +- id: tool-subagent-list-agents + disabled: true + +- id: tool-subagent + disabled: true + +- id: tool-subagent-fork + disabled: true + +# `tool-subagent-report` is host-plane for the same reason as the registry, not +# because a preset may not want it: it registers a CONTINUABLE SETUP on that +# singleton rather than a tool this agent calls, and the setup list is not +# scope-aware — one copy per mounted preset means every child gets `report` +# registered once per live session, which throws on the second. + +- id: workflow-workerthread + disabled: true + +- id: tool-workflow + disabled: true + +- id: tool-ralph + disabled: true + +- id: workspace-context + disabled: true + +- id: tool-todo + disabled: true + +- id: tool-web + disabled: true + +# The preset roster. `config/agent-presets/` ships with the deployment and is +# read-only (its entries carry `system` trust); +# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and +# carries the same trust as shell access because a preset IS a composition. +# `roots` is an assembly fact, not user config: the shipped preset directory +# ships beside this file, so AppCLIEntry resolves it and patches it in — the +# same treatment `distIndex` gets on the webserver row. +- insert: + - id: agent-presets + name: '@deepseek-ai/dsh-agent-presets' + config: + default: standard diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 9b8e15fd66..4f8b8d4318 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -32,12 +32,15 @@ } }, "dependencies": { + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 997063b3bb..816f8737e7 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: 56b9363cc724515ecbd11127ea4c13aba84283df -README.zh.md: a3fe1a978de7ab5935ec527d115278703cbebcd4 +README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd +README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 diff --git a/packages/client/README.md b/packages/client/README.md index 56b9363cc7..567e10f74a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -33,6 +33,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | | [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. | +| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. | | [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index a3fe1a978d..ad6a9fb199 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -33,6 +33,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | +| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 | | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 1ae9269cb8..a7dbd94e55 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: 07849f0728aee6076b15a8216ddcab08521994d6 -README.zh.md: a7996d0f7cc2948da82877c47f9acc805be2bba8 +README.md: 85ff46052ba2f032ee6a95b16c396d45e766d3ba +README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 07849f0728..85ff46052b 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` abstraction, 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). +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` abstraction, 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 — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) 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). ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a7996d0f7c..89cbb19a98 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` 抽象,以及循环的 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)。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 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 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index b863745d3c..70ce89677c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1357,6 +1357,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // DeepSeek route so unrelated GUI journeys do not enter first-run setup. ['DEEPSEEK_API_KEY', true], ]) + /** + * Preset compositions the fixture serves. Held as state rather than + * constants so the settings editor's save and delete are exercisable: the + * roster a GUI journey sees after writing is the text it wrote. + */ + const fixturePresets = new Map<string, { trust: 'system' | 'user'; content: string }>([ + ['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }], + ['minimal', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }], + ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], + ]) + let fixtureDefaultPreset = 'standard' const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -2444,6 +2455,88 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { matched: true as const, commandId }) }, }, + agentPresets: { + // Both trusts appear, because a surface must present a locally authored + // preset differently from one the deployment vetted. + list: request => ok(request, { + presets: [...fixturePresets].map(([id, preset]) => ({ + id, + trust: preset.trust, + isDefault: id === fixtureDefaultPreset, + })), + authorable: true, + hasDocument: true, + }), + select: (request) => { + fixtureDefaultPreset = request.payload.agentPreset + return ok(request, { agentPreset: request.payload.agentPreset }) + }, + read: (request) => { + const { agentPreset } = request.payload + const preset = fixturePresets.get(agentPreset) + if (preset === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${agentPreset}"`, + details: { agentPreset, available: [...fixturePresets.keys()] }, + }) + } + return ok(request, { + agentPreset, + trust: preset.trust, + content: preset.content, + }) + }, + copy: (request) => { + const { from, agentPreset } = request.payload + const source = fixturePresets.get(from) + if (source === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${from}"`, + details: { agentPreset: from, available: [...fixturePresets.keys()] }, + }) + } + if (fixturePresets.has(agentPreset)) { + return err(request, { + code: 'agent-preset-invalid', + message: `agent preset "${agentPreset}" already exists`, + details: { agentPreset, reason: 'already exists' }, + }) + } + fixturePresets.set(agentPreset, { trust: 'user', content: source.content }) + return ok(request, { agentPreset }) + }, + // Native opens are deterministic no-op successes in this fixture, so the + // open-directory affordance renders and the path-text fallback stays a + // component-test concern. + openDocument: (request) => { + const { agentPreset } = request.payload + const existing = fixturePresets.get(agentPreset) + if (existing === undefined || existing.trust === 'system') { + return err(request, { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }) + } + return ok(request, { opened: true as const }) + }, + remove: (request) => { + const { agentPreset } = request.payload + const existing = fixturePresets.get(agentPreset) + if (existing?.trust === 'system') { + return err(request, { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }) + } + fixturePresets.delete(agentPreset) + return ok(request, {}) + }, + }, + skills: { list: (request) => { const missing = requireSession(request) @@ -2764,6 +2857,12 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) + case 'agentPreset.list': return this.api.agentPresets.list(request) + case 'agentPreset.select': return this.api.agentPresets.select(request) + case 'agentPreset.read': return this.api.agentPresets.read(request) + case 'agentPreset.copy': return this.api.agentPresets.copy(request) + case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) + case 'agentPreset.remove': return this.api.agentPresets.remove(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index aefdcdadf4..f865653b9f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -66,6 +66,24 @@ export const Config: z<ConnectionConfig> = z.object({ * keys, or key state — and a LAN client's model picker legitimately needs it. */ const PRIVILEGED_METHODS = new Set([ + // A preset composition names the plugins a session runs, so reading one is + // reconnaissance; copy and remove rearrange what the deployment offers, and + // openDocument drives the host desktop — all more than the roster beside + // them. (Authoring is copy-only, so no method here accepts composition text + // or a path; the pin is about who may manage the roster at all.) + // + // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a + // preset looks like escalation — one of them mounts the toolset that edits the + // live runtime — but `session.create` already takes an `agentPreset`, so + // pinning only the switch would leave the same capability one method over. + // The deeper reason is that the capability is not the preset's to grant: the + // deployment's own default already carries `bash` and the filesystem tools, so + // any caller that may start a session at all can already run commands as this + // process. Pinning the switch would be a fence beside an open gate. + 'agentPreset.read', + 'agentPreset.copy', + 'agentPreset.openDocument', + 'agentPreset.remove', 'host.pickDirectory', 'host.openPath', 'settings.describe', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index c61b97833b..fc6ba9a57d 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -172,6 +172,22 @@ export class FakeApiClient implements IApiClient { execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), } + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + read: (payload: { agentPreset: string }) => + this.record('agentPreset.read', payload, Promise.resolve(ok({ + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', + }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), + } + readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 59ab8e6102..e3a4d6cb26 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -159,6 +159,10 @@ describe('connection node half', () => { 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', + // A composition names the plugins a session runs: reading one is + // reconnaissance, and copy/remove/openDocument manage the roster and + // drive the host desktop. + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { const denied = fakeResponse() await routes[0]!.handler( @@ -452,13 +456,19 @@ describe('connection node half over a real HTTP server', () => { // Carries a draft credential and turns the host into a fetcher for a // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) } // The model catalog stays reachable for the same authority: a LAN // client's model picker needs it, and it carries no key or endpoint // state (404 is the empty proxy's carrier answer — the fence passed). - for (const method of ['llm.providers', 'llm.models']) { + // `agentPreset.list` joins the model catalog for the same reason: ids and + // trust only, and a LAN client's preset picker needs it. `select` is + // reachable too: `session.create` already takes an `agentPreset`, and the + // deployment's own default already carries bash, so pinning the switch + // would be a fence beside an open gate. + for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404]) } // Loopback reaches everything, configuration included. diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 3e74510dd8..1560f131d2 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -62,6 +62,15 @@ export interface ISessions { * @returns completion of the current or newly started refresh. */ refreshSubagents(parentSessionId: SessionId): Promise<void> + + /** + * Record the composition one session now runs. The agent-preset seat calls + * this after a successful blank-session switch, so the header label moves + * with the composition instead of waiting for the next full list refresh. + * @param sessionId - the switched session. + * @param agentPreset - the preset id the host confirmed. + */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void /** Clear the current selection into the no-session view state. */ clear(): void /** diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 69094f2964..cf8fa0834d 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -25,6 +25,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string + /** Agent preset the session's agent was composed from (summary passthrough). */ + agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly<Partial<SessionProjectionMap>> /** User interaction currently blocking this session, derived from live mux frames. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b251ed8ed5..ab25781353 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -536,6 +536,7 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary: { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), } }) } else { const publishedSessionId = workspaceAttachSessionId(result.error) @@ -601,6 +602,17 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary }) } + /** + * Record a host-confirmed composition switch (see ISessions.noteAgentPreset). + * @param sessionId - the switched session. + * @param agentPreset - the preset id the host confirmed. + */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.recordMutation({ kind: 'upsert', summary: { + sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset, + } }) + } + /** Apply immediately and retain for replay when a list response is in flight. */ private recordMutation(mutation: SessionListMutation): void { this.listMutations?.push(mutation) @@ -756,6 +768,7 @@ export class SessionManager { ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), + ...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}), }) this.sessions.get(frame.sessionId)?.handleBlank(frame.blank) if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) { @@ -1040,9 +1053,15 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ? { parentSessionId: mutation.summary.parentSessionId } : {}), ...(existing.origin === undefined && mutation.summary.origin !== undefined ? { origin: mutation.summary.origin } : {}), + // Newest wins, not fill-only: a blank-session preset switch replaces + // the creation-time value, and every producer of this field (the + // create echo, the select echo, a list row) reports the CURRENT one. + ...(mutation.summary.agentPreset !== undefined + ? { agentPreset: mutation.summary.agentPreset } : {}), } if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId - && filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries] + && filled.origin === existing.origin && filled.blank === existing.blank + && filled.agentPreset === existing.agentPreset) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } case 'remove': diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index e137d9597b..72edcea1c3 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -45,6 +45,12 @@ export interface SessionSummary { /** Human-facing label: durable title, project basename, then session id. */ displayTitle: string cwd?: string + /** + * Agent preset this session's agent was composed from; absent when the + * deployment composes no presets. The session header labels what the + * session actually runs rather than the deployment's current default. + */ + agentPreset?: string parentId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' @@ -392,6 +398,10 @@ export class SessionsService implements ISessions { return this.manager.refreshSubagents(parentSessionId) } + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.manager.noteAgentPreset(sessionId, agentPreset) + } + /** * Clear the current selection so the layout shows the no-session empty * state (new-session affordance and the workspace preselection flow). @@ -662,6 +672,7 @@ export class SessionsService implements ISessions { ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + ...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}), } } if (current !== undefined && currentAddress !== undefined) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 80f45db067..860936bd77 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -208,6 +208,22 @@ export class FakeApiClient implements IApiClient { execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), } + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + read: (payload: { agentPreset: string }) => + this.record('agentPreset.read', payload, Promise.resolve(ok({ + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', + }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), + } + readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index f700cb4ba1..424015f1f6 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -430,6 +430,14 @@ export class TestSessions implements ISessions { return Promise.resolve() } + /** Apply a confirmed preset switch into the fixture list, as production does. */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.list.update((draft) => { + const summary = draft.byId[sessionId] + if (summary !== undefined) draft.byId[sessionId] = { ...summary, agentPreset } + }) + } + /** Clear the current selection (recorded; the production no-session flow). */ clear(): void { this.calls.push({ method: 'clear', args: [] }) diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 08a82ee251..0aeede27ac 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -221,6 +221,13 @@ describe('sessions', () => { .toMatchObject({ displayTitle: 'renamed', running: true }) runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true) await runtime.sessions.refreshSubagents('s2' as SessionId) + // The confirmed-switch write-back lands on the row it names and ignores + // one the fixture never added, exactly as production's list upsert does. + runtime.sessions.noteAgentPreset('s1' as SessionId, 'minimal') + runtime.sessions.noteAgentPreset('missing' as SessionId, 'minimal') + await runtime.flush() + expect(runtime.sessions.list.getSnapshot().byId['s1' as SessionId]) + .toMatchObject({ agentPreset: 'minimal' }) runtime.sessions.open('s1' as SessionId) await runtime.flush() expect(runtime.sessions.list.getSnapshot().current).toBe('s1') diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml new file mode 100644 index 0000000000..6943e47673 --- /dev/null +++ b/packages/client/ui-agent-preset/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/ui-agent-preset/README.md +README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c +README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md new file mode 100644 index 0000000000..32a4e7d9e2 --- /dev/null +++ b/packages/client/ui-agent-preset/README.md @@ -0,0 +1,67 @@ +# dsh-client-ui-agent-preset + +English | [中文](README.zh.md) + +The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. + +## Why it is a new-session preference + +A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with. + +## The new-session chip + +A second surface, beside the workspace picker on the new-session screen. It sits there rather than in the composer because that is where the choice is still open: a control that spends most of its life disabled belongs on the screen where it still works. + +The chip opens on the deployment default and its pick is *staged* — the screen precedes the session it would apply to. The stage reaches a session when one becomes current and is still blank, which covers both the session the workspace connect created and the blank one it reused; riding along on `sessions.create` would miss the second. It is spent on first use, so the next new session opens on the default again, exactly like the workspace picker beside it. + +A session that has started is refused rather than queued: the host answers `agent-preset-locked`, and the stage is dropped instead of waiting for a session that will never accept it. + +## The session-header label + +A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. + +## What it reads and writes + +Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. + +A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted. + +The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it. + +## The management section + +A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions. + +The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing. + +A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode. + +Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default. + +The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure. + +Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. + +A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start. + +Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget). + +`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it. + +## When the surfaces are absent + +A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails. + +## Model Experience + +Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model. + +#### KV Cache effect + +No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition. + +## Known Limitations and Deferred Work + +- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source. +- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself. +- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md new file mode 100644 index 0000000000..b65a1bdf92 --- /dev/null +++ b/packages/client/ui-agent-preset/README.zh.md @@ -0,0 +1,67 @@ +# dsh-client-ui-agent-preset + +[English](README.md) | 中文 + +agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。 + +## 为什么它是"新建会话"的偏好设置 + +会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。 + +## 新建会话 chip + +第二个表层,位于新建会话界面上、工作区选择器旁边。它落在这里而非 composer,是因为这里才是选择仍然成立的地方:一个大部分时间处于禁用状态的控件,属于它仍然可用的那个界面。 + +chip 以部署默认值打开,其选择是**暂存**的——该界面先于它要应用到的会话存在。暂存值会在某个会话成为当前会话且仍为空白时抵达该会话;这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。暂存值一经使用即被清空,因此下一个新会话重新以默认值打开——与它旁边的工作区选择器完全一致。 + +已经开始的会话会被直接拒绝而非排队:宿主返回 `agent-preset-locked`,暂存值随之丢弃,而不是去等一个永远不会接受它的会话。 + +## 会话标题旁的标签 + +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 + +## 它读什么、写什么 + +选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。 + +本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。 + +本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。 + +## 管理分区 + +第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示。 + +浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。 + +随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + +复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。 + +对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。 + +删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 + +名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。 + +设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 + +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。 + +## 何时不显示这些表层 + +未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框。 + +## Model Experience + +Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model. + +#### KV Cache effect + +没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。 + +## Known Limitations and Deferred Work + +- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。 +- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。 +- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json new file mode 100644 index 0000000000..6b42c14ec2 --- /dev/null +++ b/packages/client/ui-agent-preset/package.json @@ -0,0 +1,74 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-agent-preset", + "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", + "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-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "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-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-web-react": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css new file mode 100644 index 0000000000..5468f0d592 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -0,0 +1,23 @@ +/* Session-header agent-preset label: static chrome, never a control. */ + +.label { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 180px; + padding: 0 8px; + height: 22px; + border-radius: 6px; + background: var(--dsw-alias-fill-tsp-secondary); + font-size: 12px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.icon { + flex: none; + opacity: 0.7; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx new file mode 100644 index 0000000000..82688dd7c2 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -0,0 +1,62 @@ +/** + * The session header's agent-preset label. + * + * Read-only by construction: a session's composition is fixed once its + * conversation starts, and a header is only worth reading after that. Offering + * a control here would promise a switch the host refuses; naming what the + * session runs is the honest affordance, and the choice itself lives on the + * new-session screen ({@link AgentPresetSeat}). + */ + +import { useEffect } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: pulls the ui-conversation SlotMap merge (the header actions). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { AgentPresetSettingsState } from './settings-store.ts' +import css from './AgentPresetLabel.module.css' + +/** Registration-side business face for the header label. */ +export interface AgentPresetLabelInjected { + hooks: { + /** Roster snapshot bound by the renderer as useAgentPresets. */ + agentPresets: SnapshotStore<AgentPresetSettingsState> + } + /** Read the roster, so the label can show a name rather than an id. */ + load: () => Promise<void> +} + +/** Full component props. */ +export type AgentPresetLabelProps = + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale<'settings.agentPreset'> + & InjectFace<AgentPresetLabelInjected> + +/** + * Render this session's agent-preset name beside its title. + * @param props - composed slot props. + * @returns the label, or null when the session records no preset. + */ +export function AgentPresetLabel({ + sessionId, useSessions, useAgentPresets, load, t, +}: AgentPresetLabelProps) { + const preset = useSessions(state => state.byId[sessionId]?.agentPreset) + const options = useAgentPresets(state => state.options) + + useEffect(() => { + // Deployments that compose no presets never label anything, so the roster + // is only worth a request once a session reports one. + if (preset !== undefined) void load() + }, [preset, load]) + + if (preset === undefined) return null + + const option = options.find(entry => entry.id === preset) + return ( + <span className={css.label} title={option?.description ?? t('headerHint')}> + <IconThinkOutline16 className={css.icon} /> + {option?.name ?? preset} + </span> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css new file mode 100644 index 0000000000..d0f7134329 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css @@ -0,0 +1,60 @@ +/* Agent-preset row: title/description plus the preset selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx new file mode 100644 index 0000000000..ba875b0b95 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -0,0 +1,89 @@ +/** + * Agent-preset preference row: the preset new sessions are composed from. + * A running session keeps the composition it began with, so this row never + * disturbs work in progress. + */ + +import { useEffect, useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { AgentPresetSettingsState } from './settings-store.ts' +import type { AgentPresetSettingsKey } from './locales.ts' +import { PresetMenu } from './PresetMenu.tsx' +import css from './AgentPresetRow.module.css' + +/** Registration-side business face for the host-backed preference. */ +export interface AgentPresetRowInjected { + hooks: { + /** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */ + agentPreset: SnapshotStore<AgentPresetSettingsState> + } + /** Load the roster when the row first renders. */ + load: () => Promise<void> + /** Persist one preset as the default for later sessions. */ + select: (id: string) => Promise<void> +} + +/** Full component props. */ +export type AgentPresetRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'settings.agentPreset'> + & InjectFace<AgentPresetRowInjected> + +/** + * Render the new-session agent-preset selector. + * @param props - composed slot props. + * @returns the row, or null when the deployment composes no presets. + */ +export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) { + const state = useAgentPreset(snapshot => snapshot) + const [open, setOpen] = useState(false) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (state.writable && state.status !== 'unavailable') return + setOpen(false) + }, [state.status, state.writable]) + + // A deployment that composes no presets has nothing to choose between, and + // every session shares the host composition — the row simply does not exist. + if (state.status === 'unavailable') return null + const busy = state.status === 'loading' || state.status === 'saving' + // The metadata name is what every other surface shows — the id is the + // addressing, not the label. A preset that names itself nothing falls back + // to its id, which is then all there is to say about it. + const chosen = state.options.find(option => option.id === state.currentValue) + const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue) + const description: string = state.error ?? t('description') + + return ( + <div className={css.row}> + <div className={css.rowText}> + <div className={css.title}>{t('title')}</div> + <div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div> + </div> + <PresetMenu + options={state.options} + selectedId={state.currentValue} + label={label} + userTrustLabel={t('userTrust')} + buttonClassName={css.selector} + chevronClassName={css.chevron} + disabled={busy || !state.writable || state.options.length === 0} + open={open} + onOpenChange={setOpen} + onSelect={(id) => { void select(id) }} + /> + </div> + ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent-preset row copy. */ + 'settings.agentPreset': AgentPresetSettingsKey + } +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css new file mode 100644 index 0000000000..a4e4c50309 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -0,0 +1,64 @@ +/* Agent-preset chip on the new-session screen, beside the workspace picker. + Geometry mirrors HeroShell's .workspace so the two read as one row. */ + +.seat { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: min(100%, 240px); + min-height: 28px; + padding: 0 8px; + border: none; + border-radius: 12px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.seat:not(:disabled):hover, +.seat[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); +} + +.seat:disabled { + cursor: default; + color: var(--dsw-alias-label-quaternary); +} + +.seatIcon { + flex: none; + color: var(--dsw-alias-label-primary); +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-caption); +} + +/* Menu rows carry the name over its description: the id alone never said what + a preset does, which is why the metadata exists. */ +.item { + display: flex; + flex-direction: column; + gap: 2px; + max-width: 280px; +} + +.itemName { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.itemDesc { + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-caption); + white-space: normal; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx new file mode 100644 index 0000000000..8e18471fbc --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -0,0 +1,100 @@ +/** + * The agent-preset chip on the new-session screen, beside the workspace + * picker. + * + * It lives here rather than in the composer because the choice is only + * available before a conversation starts: once a turn has run, the session's + * history was produced under that preset's tools and the host refuses to swap + * them. A control that spends most of its life disabled belongs on the screen + * where it still works. + * + * The menu opens on the staged choice, which starts as the deployment default. + * Picking stages; the choice reaches a session when one becomes current. + */ + +import { useEffect, useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: pulls the ui-conversation SlotMap merge (the hero seat). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { AgentPresetSeatState } from './seat-store.ts' +import css from './AgentPresetSeat.module.css' + +/** Registration-side business face for the hero chip. */ +export interface AgentPresetSeatInjected { + hooks: { + /** Seat snapshot bound by the renderer as useAgentPresetSeat. */ + agentPresetSeat: SnapshotStore<AgentPresetSeatState> + } + /** Read the roster when the chip first renders. */ + load: () => Promise<void> + /** Stage one preset for the next session. */ + select: (id: string) => Promise<void> +} + +/** Full component props. */ +export type AgentPresetSeatProps = + PropsRuntime<'conversation.hero.agentPreset'> + & PropsLocale<'settings.agentPreset'> + & InjectFace<AgentPresetSeatInjected> + +/** + * Render the new-session agent-preset chip. + * @param props - composed slot props. + * @returns the chip, or null when the deployment composes no presets. + */ +export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { + const state = useAgentPresetSeat(snapshot => snapshot) + const [open, setOpen] = useState(false) + + useEffect(() => { + void load() + }, [load]) + + // Nothing to choose between: the deployment composes no presets and every + // session shares the host composition. + if (state.options.length === 0 || state.current === '') return null + + const chosen = state.options.find(option => option.id === state.current) + + return ( + <Menu + open={open} + onClose={() => { setOpen(false) }} + items={state.options.map(option => ({ + id: option.id, + // Name and description together: the id alone never said what a + // preset does, which is the whole reason the metadata exists. + label: ( + <span className={css.item}> + <span className={css.itemName}>{option.name ?? option.id}</span> + <span className={css.itemDesc}>{option.description ?? t('noDescription')}</span> + </span> + ), + }))} + selectedId={state.current} + onSelect={(id) => { + setOpen(false) + void select(id) + }} + align="start" + portal + anchor={( + <button + type="button" + className={css.seat} + aria-haspopup="menu" + aria-expanded={open} + title={state.error ?? t('seatHint')} + disabled={state.busy} + onClick={() => { setOpen(value => !value) }} + > + <IconThinkOutline16 className={css.seatIcon} /> + {chosen?.name ?? state.current} + <IconChevronDownOutline14 className={css.chevron} /> + </button> + )} + /> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css new file mode 100644 index 0000000000..f29bf7cdf5 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -0,0 +1,388 @@ +.section { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 720px; + color: var(--dsw-alias-label-primary); +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.intro { + margin: 0; + font-size: 13px; + color: var(--dsw-alias-label-tertiary); +} + +/* Cards, not rows: a preset is a thing you pick, and the description is the + part that tells them apart — a row would bury it beside the actions. */ +.group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.groupHead { + margin: 0; + font-size: 12px; + font-weight: 600; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--dsw-alias-label-tertiary); +} + +.cards { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(268px, 1fr)); + /* Every row the same height, so a short description does not make its card + shorter than the one beside it. */ + grid-auto-rows: 1fr; + gap: 12px; +} + +.card { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + display: flex; + flex-direction: column; + background: var(--dsw-alias-bg-layer-3); + transition: border-color .16s, background .16s; +} + + +.card:hover:not(.cardActive) { + border-color: var(--dsw-alias-label-dimmed); +} + +/* The default preset reads as selected, not merely badged. */ +.cardActive { + background: var(--dsw-alias-bg-layer-2); + border-color: var(--dsw-alias-label-primary); +} + +/* A broken preset reads as damaged before anything else: the card cannot be + picked, so its border carries the warning the disabled body cannot. */ +.cardBroken { + border-color: var(--dsw-alias-state-error-primary); +} + +.cardBroken:hover { + border-color: var(--dsw-alias-state-error-primary); +} + +.brokenBadge { + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 17px; + white-space: nowrap; + font-weight: 500; + background: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-bg-layer-3); +} + +/* The discovery-reported reason, verbatim: it names the file and the fix. */ +.cardBrokenReason { + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} + +/* The card body is the control that picks the preset. */ +.cardMain { + flex: 1; + appearance: none; + border: 0; + background: none; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px 12px; + border-radius: 12px 12px 0 0; +} + +.cardMain:disabled { + cursor: default; +} + +.cardMain:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + +.cardHead { + display: flex; + align-items: center; + gap: 8px; +} + +.cardName { + font-size: 15px; + font-weight: 600; + line-height: 1.4; +} + +.badge, +.inUse { + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 17px; + white-space: nowrap; + font-weight: 500; +} + +.badge { + border: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-tertiary); +} + +.inUse { + margin-left: auto; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.cardDesc { + font-size: 13px; + line-height: 1.55; + color: var(--dsw-alias-label-secondary); + flex: 1; + min-height: 42px; +} + +.cardId { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; + color: var(--dsw-alias-label-dimmed); +} + +.cardFoot { + display: flex; + justify-content: flex-end; + gap: 2px; + padding: 6px 10px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + +/* Icon-only actions: the label rides `title` so the row stays quiet until + someone reaches for it. */ +.iconButton { + position: relative; + appearance: none; + border: 0; + border-radius: 7px; + padding: 6px; + background: none; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.iconButton:disabled { + opacity: 0.4; + cursor: default; +} + +.iconButton:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.iconButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -1px; +} + +.iconButton::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + line-height: 17px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.iconButton:hover::after, +.iconButton:focus-visible::after { + opacity: 1; +} + +.iconDanger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); +} + +/* Where the host has no desktop opener, the row answers with the directory + itself — text to copy, not a control that would spawn into nothing. */ +.revealedPath { + margin: 0; + padding: 6px 16px 10px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); + display: flex; + gap: 6px; + align-items: baseline; +} + +.revealedPath code { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + color: var(--dsw-alias-label-secondary); + user-select: all; + overflow-wrap: anywhere; +} + +.revealedPathLabel { + white-space: nowrap; +} + +.secondaryButton { + border: none; + border-radius: 7px; + padding: 5px 8px; + background: none; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + + +.secondaryButton:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); +} + +.secondaryButton:disabled { + opacity: 0.5; + cursor: default; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fieldLabel { + font-size: 12px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.input { + box-sizing: border-box; + padding: 9px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + font: inherit; + font-size: 13px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.input:focus { + outline: none; + border-color: var(--dsw-alias-brand-primary); +} + +.input::placeholder { + color: var(--dsw-alias-label-dimmed); +} + +.dialog { + width: min(560px, 100%); +} + +.dialogFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* A shipped composition can be long; the dialog scrolls it rather than grow. */ +.viewerCode { + margin: 0; + padding: 12px; + max-height: min(52vh, 480px); + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + white-space: pre; + tab-size: 2; + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.error { + margin: 0; + font-size: 12px; + color: var(--dsw-alias-state-error-primary); +} + +.deleteDialog { + width: min(480px, 100%); +} + +.deleteConfirm:not(:disabled) { + border-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* The conversational authoring entry, after the card grid in the spot the + create button vacated. Dashed like the Models page's add affordances: it + reads as a place a preset will appear, not a command. */ +.creatorButton { + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 44px; + border: 1px dashed var(--dsw-alias-border-l3); + border-radius: 12px; + font: inherit; + font-size: 13px; + background: none; + color: inherit; + cursor: pointer; +} + +.creatorButton:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); +} + +.creatorButton:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx new file mode 100644 index 0000000000..3a9d0b960a --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -0,0 +1,372 @@ +/** + * Agent-presets settings section: the roster as cards, a copy dialog as the + * only way a preset is created, and a read-only viewer over the shipped + * compositions. + * + * The browser edits no composition text — a shipped preset opens read-only to + * be READ (it is the known-good composition a copy starts from), and a custom + * preset is edited in its own files, which is what the location action leads + * to. Deleting a preset leaves running sessions alone: a composition is + * mounted once at session creation and nothing re-reads the file. + */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { + Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' +import type { AgentPresetSettingsKey } from './locales.ts' +import css from './AgentPresetSection.module.css' + +/** Registration-side business face for the management section. */ +export interface AgentPresetSectionInjected { + hooks: { + /** Page snapshot bound by the renderer as useAgentPresetSection. */ + agentPresetSection: SnapshotStore<AgentPresetSectionState> + } + /** Read the roster; called once when the section first renders. */ + load: () => Promise<void> + /** Open one shipped preset's composition in the read-only viewer. */ + view: (id: string) => Promise<void> + /** Close the read-only viewer. */ + closeView: () => void + /** Open the copy dialog over one preset. */ + beginCopy: (from: string) => void + /** Close the copy dialog, discarding the draft. */ + cancelCopy: () => void + /** Name the preset the copy creates. */ + setCopyId: (id: string) => void + /** Name the copy's display name. */ + setCopyName: (name: string) => void + /** Submit the copy. */ + confirmCopy: () => Promise<void> + /** Open one preset's directory, or reveal its path where there is no desktop. */ + openLocation: (id: string) => Promise<void> + /** + * Stage the self-referential preset and start a new session on it — the + * guided way to author a preset, beside copying. Absent when the surface + * is composed without the conversation flow to land the session in. + */ + startCreatorDraft?: () => void + /** Ask for delete confirmation, or dismiss it with null. */ + confirmDelete: (id: string | null) => void + /** Delete the preset awaiting confirmation. */ + remove: () => Promise<void> + /** Make one preset the default for sessions created later. */ + makeDefault: (id: string) => Promise<void> +} + +/** Full component props. */ +export type AgentPresetSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.agentPreset'> + & InjectFace<AgentPresetSectionInjected> + +/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */ +interface CopyDialogProps { + state: AgentPresetSectionState + t: (key: AgentPresetSettingsKey) => string + actions: Pick<AgentPresetSectionInjected, + 'cancelCopy' | 'confirmCopy' | 'setCopyId' | 'setCopyName'> +} + +function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { + const draft = state.copy + const blocker = draft === null ? undefined : draftBlocker(draft, state.rows) + const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker)) + return ( + <Modal + open={draft !== null} + onClose={() => { actions.cancelCopy() }} + title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`} + closeLabel={t('close')} + description={t('copyIntro')} + className={css.dialog as string} + footer={( + <> + <Button + variant="outline" + disabled={draft?.saving === true} + onClick={() => { actions.cancelCopy() }} + > + {t('cancel')} + </Button> + <Button + disabled={draft === null || draft.saving || blocker !== undefined} + onClick={() => { void actions.confirmCopy() }} + > + {draft?.saving === true ? t('creating') : t('create')} + </Button> + </> + )} + > + {draft === null + ? null + : ( + <div className={css.dialogFields}> + <label className={css.field}> + <span className={css.fieldLabel}>{t('presetId')}</span> + <input + className={css.input} + value={draft.id} + autoFocus + spellCheck={false} + placeholder={t('presetIdPlaceholder')} + onChange={(event) => { actions.setCopyId(event.target.value) }} + /> + </label> + <label className={css.field}> + <span className={css.fieldLabel}>{t('displayName')}</span> + <input + className={css.input} + value={draft.name} + spellCheck={false} + placeholder={t('displayNamePlaceholder')} + onChange={(event) => { actions.setCopyName(event.target.value) }} + /> + </label> + {message === null ? null : <p className={css.error} role="alert">{message}</p>} + </div> + )} + </Modal> + ) +} + +/** + * Render the Agent presets section content column. + * @param props - composed slot props. + * @returns the section, or null when the deployment composes no presets. + */ +export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { + const { useAgentPresetSection, t, load } = props + const state = useAgentPresetSection(snapshot => snapshot) + + useEffect(() => { + void load() + }, [load]) + + // A deployment that composes no presets has nothing to manage: every + // session shares the host composition and the page would be an empty list. + if (state.status === 'unavailable') return null + if (state.status === 'error') { + /* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */ + const detail = state.error ?? '' + return ( + <div className={css.section}> + <p className={css.error} role="alert">{`${t('error')} ${detail}`}</p> + <button type="button" className={css.secondaryButton} onClick={() => { void load() }}> + {t('retry')} + </button> + </div> + ) + } + + return ( + <div className={css.section}> + <h2 className={css.title}>{t('nav')}</h2> + <p className={css.intro}>{t('sectionIntro')}</p> + {state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>} + {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => { + const group = state.rows.filter(row => row.trust === trust) + if (group.length === 0) return null + return ( + <section key={trust} className={css.group}> + <h3 className={css.groupHead}>{heading}</h3> + <ul className={css.cards}> + {group.map(row => ( + <li + key={row.id} + className={row.broken !== undefined + ? `${css.card} ${css.cardBroken}` + : row.isDefault ? `${css.card} ${css.cardActive}` : css.card} + > + {/* The card body IS the control: picking a preset is the + common act, so it should not hide behind a small button. + The action row sits outside it — nesting buttons is + invalid, and these act on the card rather than select it. + A broken preset cannot compose a session, so its body is + disabled and the card says why instead of offering it. */} + <button + type="button" + className={css.cardMain} + aria-pressed={row.isDefault} + disabled={row.isDefault || row.broken !== undefined} + // Without this the name is the whole card read aloud — + // title, badge, description, id. + aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`} + title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))} + onClick={() => { void props.makeDefault(row.id) }} + > + <span className={css.cardHead}> + <span className={css.cardName}>{row.name ?? row.id}</span> + {row.broken !== undefined + ? <span className={css.brokenBadge}>{t('brokenBadge')}</span> + : null} + <span className={css.badge}> + {row.trust === 'user' ? t('userTrust') : t('builtIn')} + </span> + {row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null} + </span> + <span className={css.cardDesc}>{row.description ?? t('noDescription')}</span> + {row.broken === undefined + ? null + : <span className={css.cardBrokenReason} role="alert">{row.broken}</span>} + <code className={css.cardId}>{row.id}</code> + </button> + <div className={css.cardFoot}> + {/* Shipped presets are the compositions a copy starts + from, so READING one is the point; a custom preset is + edited in its files instead, which the location action + leads to. A broken shipped preset has no readable + composition to offer, so its viewer is withheld; a + broken custom one keeps the location action — the + files are where it gets fixed. */} + {row.trust === 'system' + ? row.broken === undefined + ? ( + <button + type="button" + className={css.iconButton} + data-tip={t('view')} + aria-label={`${t('view')}: ${row.name ?? row.id}`} + onClick={() => { void props.view(row.id) }} + > + <IconBrowseOutline16 /> + </button> + ) + : null + : ( + <button + type="button" + className={css.iconButton} + data-tip={state.hasDocument ? t('openLocation') : t('showLocation')} + aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`} + onClick={() => { void props.openLocation(row.id) }} + > + <IconFolderOpenOutline16 /> + </button> + )} + <button + type="button" + className={css.iconButton} + disabled={!state.authorable || row.broken !== undefined} + data-tip={row.broken !== undefined + ? t('brokenNoCopy') + : state.authorable ? t('duplicate') : t('duplicateUnavailable')} + aria-label={`${t('duplicate')}: ${row.name ?? row.id}`} + onClick={() => { props.beginCopy(row.id) }} + > + <IconCopyOutline16 /> + </button> + {row.trust === 'user' + ? ( + <button + type="button" + className={`${css.iconButton} ${css.iconDanger}`} + data-tip={t('delete')} + aria-label={`${t('delete')}: ${row.name ?? row.id}`} + onClick={() => { props.confirmDelete(row.id) }} + > + <IconTrashOutline16 /> + </button> + ) + : null} + </div> + {state.revealedPaths[row.id] === undefined + ? null + : ( + <p className={css.revealedPath}> + <span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span> + <code>{state.revealedPaths[row.id]}</code> + </p> + )} + </li> + ))} + </ul> + </section> + ) + })} + {/* The guided alternative to copying: the self-referential preset can + read this very composition and author a new one in conversation. + Offered only where that preset is actually on the roster and a + session can be landed; without a writable root the draft could + never be discovered, so the reason rides the disabled button. */} + {props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') + ? ( + <button + type="button" + className={css.creatorButton} + disabled={!state.authorable} + title={state.authorable ? undefined : t('duplicateUnavailable')} + onClick={() => { + props.startCreatorDraft?.() + props.close() + }} + > + {/* Same glyph as the Models page's add affordances. */} + <IconPlusOutline16 size={14} /> + {t('creatorDraft')} + </button> + ) + : null} + <CopyDialog + state={state} + t={t} + actions={{ + cancelCopy: props.cancelCopy, + confirmCopy: props.confirmCopy, + setCopyId: props.setCopyId, + setCopyName: props.setCopyName, + }} + /> + <Modal + open={state.view !== null} + onClose={() => { props.closeView() }} + title={state.view === null ? '' : `${t('view')} · ${state.view.title}`} + closeLabel={t('close')} + description={t('composition')} + className={css.dialog as string} + footer={( + <Button variant="outline" autoFocus onClick={() => { props.closeView() }}> + {t('close')} + </Button> + )} + > + {state.view === null + ? null + : <pre className={css.viewerCode}>{state.view.content}</pre>} + </Modal> + <Modal + open={state.pendingDelete !== null} + onClose={() => { props.confirmDelete(null) }} + title={t('deleteTitle')} + closeLabel={t('close')} + description={t('deleteDescription')} + className={css.deleteDialog as string} + footer={( + <> + <Button + variant="outline" + autoFocus + disabled={state.deleting} + onClick={() => { props.confirmDelete(null) }} + > + {t('cancel')} + </Button> + <Button + variant="outline" + className={css.deleteConfirm} + disabled={state.deleting} + onClick={() => { void props.remove() }} + > + {state.deleting ? t('deleting') : t('deleteConfirm')} + </Button> + </> + )} + /> + </div> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx new file mode 100644 index 0000000000..2a6bc6ea28 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx @@ -0,0 +1,83 @@ +/** + * The preset picker both surfaces render: a menu of presets over a button + * naming the current one. + * + * The settings row and the composer seat differ in where they sit, what they + * call the current value, and when they refuse a pick — not in how the picker + * itself behaves. Trust is the one thing the list always says: a locally + * authored preset is exactly as privileged as the plugins it names, so the + * label marks it rather than presenting every preset as shipped and vetted. + */ + +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { AgentPresetOption } from './settings-store.ts' + +/** What one surface passes to the shared picker. */ +export interface PresetMenuProps { + /** Presets to offer, in roster order. */ + options: readonly AgentPresetOption[] + /** The preset the button names and the menu marks selected. */ + selectedId: string + /** Text on the button; the surfaces word a pending roster differently. */ + label: string + /** Suffix marking a locally authored preset in the menu. */ + userTrustLabel: string + /** Class for the trigger button, owned by the calling surface. */ + buttonClassName: string | undefined + /** Class for the chevron, owned by the calling surface. */ + chevronClassName: string | undefined + /** Whether the trigger refuses interaction. */ + disabled: boolean + /** Whether the menu is open — the surface owns this so it can force it shut. */ + open: boolean + /** Report the menu's next open state. */ + onOpenChange: (open: boolean) => void + /** Called with the picked preset once the menu has closed. */ + onSelect: (id: string) => void +} + +/** + * Render the preset picker. + * @param props - the calling surface's copy, styling, and handlers. + * @returns the menu and its trigger. + */ +export function PresetMenu({ + options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName, + disabled, open, onOpenChange, onSelect, +}: PresetMenuProps) { + return ( + <Menu + open={open} + onClose={() => { onOpenChange(false) }} + items={options.map(option => ({ + id: option.id, + // The metadata name is what every surface shows; the id is addressing, + // not a label. A preset that names itself nothing falls back to its id, + // which is then all there is to say about it. + label: option.trust === 'user' + ? `${option.name ?? option.id} · ${userTrustLabel}` + : option.name ?? option.id, + }))} + selectedId={selectedId} + onSelect={(id) => { + onOpenChange(false) + onSelect(id) + }} + align="end" + portal + anchor={( + <button + type="button" + className={buttonClassName} + aria-haspopup="menu" + aria-expanded={open} + disabled={disabled} + onClick={() => { onOpenChange(!open) }} + > + {label} + <IconChevronDownOutline14 className={chevronClassName} /> + </button> + )} + /> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts new file mode 100644 index 0000000000..97a7f66ce2 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -0,0 +1,209 @@ +/** + * Agent-preset surface plugin, browser half — four surfaces over one roster: + * a General-settings row for the default preset, a chip on the new-session + * screen for the session about to start, a read-only label in the session + * header, and a settings section that manages the roster (copy, delete, + * default, and the way into a preset's own files). + * + * A running session keeps the composition it began with (the host refuses to + * adopt an existing session under a different preset). That is what splits + * the choice from the display: the General row and the hero chip are both + * before-the-fact, while the header only reports what a session already runs. + */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetLabel } from './AgentPresetLabel.tsx' +import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx' +import { AgentPresetRow } from './AgentPresetRow.tsx' +import type { AgentPresetRowInjected } from './AgentPresetRow.tsx' +import { AgentPresetSeat } from './AgentPresetSeat.tsx' +import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' +import { AgentPresetSection } from './AgentPresetSection.tsx' +import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx' +import { AgentPresetSeatController } from './seat-store.ts' +import type { SeatSessionSummary } from './seat-store.ts' +import { AgentPresetSectionController } from './section-store.ts' +import { en, zh } from './locales.ts' +import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' + +export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx' +export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' +export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' +export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' +export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts' +export { + draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView, +} from './section-store.ts' +export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' +export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'connection'] + +/** + * Mount the General-settings row. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const { api } = ctx.get('connection') as ConnectionHandle + const controller = new AgentPresetSettingsController(api) + // One roster, four surfaces. The chip is registered in a later scope, so it + // subscribes here rather than being reached from this one. + const rosterReaders = new Set<() => void>() + const section = new AgentPresetSectionController(api, () => { + void controller.load() + for (const read of rosterReaders) read() + }) + + ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') + + const injected = (): AgentPresetRowInjected => ({ + hooks: { agentPreset: controller.store }, + load: () => controller.load(), + select: (id: string) => controller.select(id), + }) + + ctx.effect(() => { + // The roster is a live directory and the default is a settings field, so + // both an external settings edit and a reconnect can move this row. + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return + void controller.load() + // The section reads the same roster and marks the same default, so a + // change made from either surface converges both. + if (section.store.getSnapshot().status !== 'idle') void section.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-agent-preset: settings refresh') + + // The settings section's conversational authoring entry: stage the + // self-referential preset and land a new session on it. Bound inside the + // conversation scope below (the seat and the session flow live there) and + // unbound with it, so the section's face reads the current binding per + // render and simply hides the button while no flow exists. + let creatorDraft: (() => void) | undefined + + // The new-session chip and the header label: one controller, because the + // staged choice belongs to the flow rather than to any one session. + ctx.inject(['slots', 'conversation', 'sessions', 'workspaces'], (scope: ClientContext) => { + const api = (scope.get('connection') as ConnectionHandle).api + const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => { + const state = scope.sessions.list.getSnapshot() + const summary = state.current === undefined ? undefined : state.byId[state.current] + return summary === undefined + ? undefined + : { + id: summary.id, + blank: summary.blank, + ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }, + } + }, (sessionId, agentPreset) => { + scope.sessions.noteAgentPreset(sessionId as never, agentPreset) + }) + + const seatInjected = (): AgentPresetSeatInjected => ({ + hooks: { agentPresetSeat: seat.store }, + load: () => seat.load(), + select: (id: string) => seat.select(id), + }) + + const labelInjected = (): AgentPresetLabelInjected => ({ + hooks: { agentPresets: controller.store }, + load: () => controller.load(), + }) + + scope.effect(() => { + // Connecting a workspace either creates a blank session or reuses one, + // and either way the chip's pick predates it — so the stage is applied + // when the session arrives, not when it was made. + const stop = scope.sessions.list.subscribe(() => { void seat.apply() }) + // The chip opens on the deployment default, so a default changed from + // the settings surface moves it too — otherwise the screen that starts + // the next session keeps offering the previous default until a reload, + // which is exactly the session the setting claims to govern. A staged + // pick survives: `load()` prefers it over the refreshed fallback. + const settingsMoved = scope.on('settings/changed', (ns?: string) => { + if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return + void seat.load() + }) + // Authoring writes a FILE, not a setting, so nothing on the wire + // announces it — without this the screen that starts the next session + // keeps offering the roster as it stood when the chip first loaded, and + // a preset authored to be used is missing from the one place it is used. + const readRoster = (): void => { void seat.load() } + rosterReaders.add(readRoster) + // Stage WITHOUT applying — the still-current running session would + // refuse the swap and drop the stage — then start the session it lands + // on: the chip's list-change applier composes the blank session the + // workspace connect produces or reuses. + creatorDraft = () => { + seat.stage('cordis') + scope.workspaces.startSession() + } + const chip = scope.slots.register({ + name: 'conversation.hero.agentPreset', + locale: 'settings.agentPreset', + inject: seatInjected, + }, AgentPresetSeat) + const label = scope.slots.register({ + name: 'conversation.session.header.actions', + id: 'agent-preset', + order: 20, + locale: 'settings.agentPreset', + inject: labelInjected, + }, AgentPresetLabel) + return () => { + stop() + settingsMoved() + rosterReaders.delete(readRoster) + creatorDraft = undefined + chip() + label() + } + }, 'ui-agent-preset: new-session chip and header label') + }) + + const sectionInjected = (): AgentPresetSectionInjected => ({ + hooks: { agentPresetSection: section.store }, + load: () => section.load(), + view: (id: string) => section.view(id), + closeView: () => { section.closeView() }, + beginCopy: (from: string) => { section.beginCopy(from) }, + cancelCopy: () => { section.cancelCopy() }, + setCopyId: (id: string) => { section.setCopyId(id) }, + setCopyName: (name: string) => { section.setCopyName(name) }, + confirmCopy: () => section.confirmCopy(), + openLocation: (id: string) => section.openLocation(id), + ...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft }, + confirmDelete: (id: string | null) => { section.confirmDelete(id) }, + remove: () => section.remove(), + makeDefault: (id: string) => section.makeDefault(id), + }) + + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'agent-preset', + order: -25, + locale: 'settings.agentPreset', + inject: injected, + }, AgentPresetRow)) + // Ordered after Models: choosing a model is routine, and composing an + // agent is the deployment-shaping act behind it. + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'agent-presets', + order: 20, + label: () => ctx.locale.bind('settings.agentPreset')('nav'), + locale: 'settings.agentPreset', + inject: sectionInjected, + }, AgentPresetSection)) +} diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts new file mode 100644 index 0000000000..50a4e36138 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -0,0 +1,118 @@ +/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */ + +/** Locale keys these surfaces render. */ +export type AgentPresetSettingsKey = + | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' + | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' + | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' + | 'displayName' | 'displayNamePlaceholder' + | 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' + | 'brokenBadge' | 'brokenNoCopy' + | 'composition' | 'cancel' | 'close' | 'retry' + | 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft' + | 'openLocation' | 'showLocation' | 'revealedPathLabel' + | 'idRequired' | 'idInvalid' | 'idTaken' + | 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting' + +/** English copy. */ +export const en: Record<AgentPresetSettingsKey, string> = { + title: 'Agent preset', + description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.', + loading: 'Loading presets…', + error: 'Could not load agent presets.', + userTrust: 'Custom', + seatHint: 'Agent preset for the session you are about to start', + headerHint: 'The agent preset this session runs, fixed when it started', + nav: 'Agent presets', + sectionIntro: + 'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. ' + + 'Duplicate an existing one and make it yours, or let the agent draft one for you in Creator mode.', + builtIn: 'Built-in', + setDefault: 'Set as default', + view: 'View', + duplicate: 'Duplicate', + duplicateUnavailable: 'This deployment has no writable preset directory', + delete: 'Delete', + presetId: 'Identifier', + presetIdPlaceholder: 'my-agent', + displayName: 'Name', + displayNamePlaceholder: 'Shown in the picker; defaults to the identifier', + inUse: 'In use', + builtInGroup: 'Built-in', + customGroup: 'Custom', + noDescription: 'No description.', + brokenBadge: 'Broken', + brokenNoCopy: 'Broken presets cannot be duplicated', + copyOf: 'Copied from', + composition: 'Composition (agent.cordis.yml)', + cancel: 'Cancel', + close: 'Close', + retry: 'Retry', + copyTitle: 'Duplicate preset', + copyIntro: + 'The whole preset is copied on this machine. The identifier becomes its directory name and cannot ' + + 'be changed later; everything else is edited in the preset\'s own files.', + create: 'Create', + creating: 'Creating…', + creatorDraft: 'Draft a custom preset with Creator mode', + openLocation: 'Open folder', + showLocation: 'Show location', + revealedPathLabel: 'Preset files:', + idRequired: 'Give the preset an identifier.', + idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.', + idTaken: 'A preset with this identifier already exists.', + deleteTitle: 'Delete this preset?', + deleteDescription: + 'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.', + deleteConfirm: 'Delete', + deleting: 'Deleting…', +} + +/** Simplified Chinese copy. */ +export const zh: Record<AgentPresetSettingsKey, string> = { + title: 'Agent 预设', + description: '对此后新建的会话生效。运行中的会话保持它开始时的预设。', + loading: '正在加载预设…', + error: '无法加载 Agent 预设。', + userTrust: '自定义', + seatHint: '即将开始的这个会话所用的 Agent 预设', + headerHint: '本会话运行的 Agent 预设,开始时即固定', + nav: 'Agent 预设', + sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。', + builtIn: '内置', + setDefault: '设为默认', + view: '查看', + duplicate: '复制', + duplicateUnavailable: '此部署未配置可写的预设目录', + delete: '删除', + presetId: '标识符', + presetIdPlaceholder: 'my-agent', + displayName: '名称', + displayNamePlaceholder: '选择器中显示的名字,缺省用标识符', + inUse: '当前使用', + builtInGroup: '内置', + customGroup: '自定义', + noDescription: '暂无描述。', + brokenBadge: '已损坏', + brokenNoCopy: '预设已损坏,无法复制', + copyOf: '复制自', + composition: '组装(agent.cordis.yml)', + cancel: '取消', + close: '关闭', + retry: '重试', + copyTitle: '复制预设', + copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。', + create: '创建', + creating: '正在创建…', + creatorDraft: '用「创造模式」创作自定义预设', + openLocation: '打开目录', + showLocation: '查看路径', + revealedPathLabel: '预设文件:', + idRequired: '请填写标识符。', + idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。', + idTaken: '该标识符已被占用。', + deleteTitle: '删除该预设?', + deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。', + deleteConfirm: '删除', + deleting: '正在删除…', +} diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts new file mode 100644 index 0000000000..27a414e4a3 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -0,0 +1,163 @@ +/** + * Hero-chip controller: which preset the NEXT session gets. + * + * The new-session screen has no session, so a pick is staged rather than + * applied. It reaches a session when one becomes current and is still blank — + * whether the workspace connect created it or reused an existing blank one, + * which is why staging cannot simply ride along on `sessions.create`. + * + * The stage is forgotten once applied: the next new session starts from the + * deployment default again, matching the workspace picker beside it. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { + createSnapshotStore, type SessionId, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import { messageOf, presetOptions } from './settings-store.ts' +import type { AgentPresetOption } from './settings-store.ts' + +/** Hero-chip snapshot. */ +export interface AgentPresetSeatState { + /** Presets the deployment supplies; empty means the chip renders nothing. */ + options: readonly AgentPresetOption[] + /** The staged choice, empty until the roster loads. */ + current: string + /** A rejected apply's message, cleared by the next attempt. */ + error: string | null + busy: boolean +} + +const INITIAL: AgentPresetSeatState = { + options: [], current: '', error: null, busy: false, +} + +/** One session's identity and whether it has started. */ +export interface SeatSessionSummary { + /** The session the chip would apply its staged choice to. */ + id: SessionId + /** False once a turn has run — applying is refused from then on. */ + blank: boolean + /** The preset the session already runs, when the summary reports one. */ + agentPreset?: string +} + +/** Stages the next session's preset and applies it when one appears. */ +export class AgentPresetSeatController { + /** Chip snapshot the renderer subscribes to. */ + readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL) + + /** + * The deployment default, so a consumed stage can fall back to it without + * re-reading the roster. + */ + private fallback = '' + + /** Set while a pick is waiting for a session; cleared once applied. */ + private staged: string | undefined + + constructor( + private readonly api: Pick<IApiClient, 'agentPresets'>, + /** The session the hero is about to hand over to, when there is one. */ + private readonly currentSession: () => SeatSessionSummary | undefined, + /** + * Publish an applied switch into the session list, so the header label + * moves with the composition instead of waiting for the next full list + * refresh. Optional: a harness that renders no list omits it. + */ + private readonly onApplied?: (sessionId: string, agentPreset: string) => void, + ) {} + + private set(patch: Partial<AgentPresetSeatState>): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + /** + * Read the roster and open the chip on the deployment default. + * @returns once the snapshot reflects the host. + */ + async load(): Promise<void> { + try { + const response = await this.api.agentPresets.list({}) + if (!response.result.ok) { + this.set({ error: response.result.error.message }) + return + } + const { presets } = response.result.value + this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' + this.set({ + options: presetOptions(presets), + // Staged pick first, then the composition the current session + // already carries, then the deployment default. The middle term is + // what keeps a late-landing load from regressing the display after + // an applied stage was consumed — the chip mounts (and loads) only + // once the flow's session is current, so the reply can arrive after + // apply() already composed it. + current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback, + error: null, + }) + } catch (error) { + this.set({ error: messageOf(error) }) + } + } + + /** + * Stage one preset for the next session, applying it immediately when a + * blank session is already current. + * @param id - the preset to stage. + * @returns once the stage settled, and the apply too when one happened. + */ + async select(id: string): Promise<void> { + if (this.store.getSnapshot().busy) return + this.stage(id) + await this.apply() + } + + /** + * Stage a pick WITHOUT the immediate apply, for a flow that starts the + * receiving session after the pick (the settings section's creator entry). + * `select()`'s immediate apply would meet the still-current running session + * and drop the stage as unservable; staging alone leaves it for the + * list-change applier, which fires when the started session becomes + * current. + * @param id - the preset to stage. + */ + stage(id: string): void { + this.staged = id + this.set({ current: id, error: null }) + } + + /** + * Hand the staged choice to the current session, if there is one to take it. + * + * Called both by `select()` and by whoever observes the current session + * changing, because the session may appear either before or after the pick. + * @returns once the switch settled, or immediately when there is nothing to do. + */ + async apply(): Promise<void> { + const staged = this.staged + const session = this.currentSession() + if (staged === undefined || session === undefined) return + // A started session's history was produced under its own composition; the + // host refuses the swap, so the stage is no longer meaningful. + if (!session.blank || session.agentPreset === staged) { + this.staged = undefined + return + } + this.set({ busy: true, error: null }) + try { + const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged }) + this.staged = undefined + if (!response.result.ok) { + this.set({ busy: false, error: response.result.error.message, current: this.fallback }) + return + } + // Consumed: the next new session opens on the deployment default again. + this.set({ busy: false, current: response.result.value.agentPreset }) + this.onApplied?.(session.id, response.result.value.agentPreset) + } catch (error) { + this.staged = undefined + this.set({ busy: false, error: messageOf(error), current: this.fallback }) + } + } +} diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts new file mode 100644 index 0000000000..df430f6db4 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -0,0 +1,347 @@ +/** + * Agent-preset management controller: the roster as a list, a copy dialog as + * the only way a preset is created, and a read-only viewer over the shipped + * compositions. + * + * The browser edits no composition text. A new preset is a host-side copy of + * an existing one (`{ from, id, name? }` is all that crosses the wire), and + * everything after creation happens in the preset's own files — which is why + * the page's other job is getting the user TO those files: open the directory + * where the host has a desktop, show its path where it does not. + * + * The host stays the single fact source. Every mutation writes through the + * wire and the page re-reads the roster afterwards, because a copy changes + * more than the row it targeted. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' + +/** Ids a preset directory may be named, mirroring the host's own rule. */ +const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ + +/** One preset row the page renders. */ +export interface PresetRow { + /** Preset id and directory name; the display name falls back to it. */ + id: string + /** Display name the preset published, absent when it published none. */ + name?: string + /** One sentence on what the preset is for. */ + description?: string + /** Whether the preset ships with the deployment or was authored locally. */ + trust: 'system' | 'user' + /** Whether a session that names no preset gets this one. */ + isDefault: boolean + /** + * Why the preset cannot compose a session, absent when it can. A broken + * row renders marked and unselectable — its directory still occupies the + * id, so deleting it (or fixing the files) is the way out, and this page + * is where both of those live. + */ + broken?: string +} + +/** The copy dialog: a new id and optional display name over a fixed source. */ +export interface CopyDraft { + /** The preset being copied. */ + from: string + /** Display name of the source, for the dialog title. */ + fromTitle: string + /** New preset id being typed; the directory name, so it is required. */ + id: string + /** Display name being typed; empty falls back to the id. */ + name: string + /** Whether the copy is in flight. */ + saving: boolean + /** The last copy failure, cleared by the next edit. */ + error: string | null +} + +/** The read-only composition viewer over one shipped preset. */ +export interface PresetView { + /** The preset whose composition is shown. */ + id: string + /** Display name, for the dialog title. */ + title: string + /** Composition text exactly as stored. */ + content: string +} + +/** Page snapshot. */ +export interface AgentPresetSectionState { + status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error' + /** Whole-load failure text; a copy failure stays on the dialog. */ + error: string | null + /** Whether the deployment configures a root new presets can be written to. */ + authorable: boolean + /** Whether the host can open a preset directory on a native desktop. */ + hasDocument: boolean + /** Every preset the deployment currently supplies. */ + rows: readonly PresetRow[] + /** The open copy dialog, or null. */ + copy: CopyDraft | null + /** The open read-only viewer, or null. */ + view: PresetView | null + /** The preset awaiting delete confirmation. */ + pendingDelete: string | null + /** Whether a delete is in flight. */ + deleting: boolean + /** + * Preset directories shown as text because the host has no desktop opener + * — the answer `openDocument` gives instead of opening. + */ + revealedPaths: Readonly<Record<string, string>> +} + +const INITIAL: AgentPresetSectionState = { + status: 'idle', + error: null, + authorable: false, + hasDocument: false, + rows: [], + copy: null, + view: null, + pendingDelete: null, + deleting: false, + revealedPaths: {}, +} + +/** + * Why this copy cannot be submitted yet, as a locale key, or undefined when + * it can. Client-side only: the host re-checks the id and its answer is what + * the dialog reports on failure. + * @param draft - the open copy dialog. + * @param rows - the roster, for the collision check. + * @returns the blocking reason's locale key, or undefined when submittable. + */ +export function draftBlocker( + draft: CopyDraft, + rows: readonly PresetRow[], +): 'idRequired' | 'idInvalid' | 'idTaken' | undefined { + if (draft.id === '') return 'idRequired' + if (!PRESET_ID.test(draft.id)) return 'idInvalid' + // A copy never overwrites: landing on a name already in use would replace + // something the user did not open. + if (rows.some(row => row.id === draft.id)) return 'idTaken' + return undefined +} + +/** Reads the roster and drives the copy dialog, viewer, and location reveals. */ +export class AgentPresetSectionController { + /** Page snapshot the renderer subscribes to. */ + readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL) + + constructor( + private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>, + /** + * Called after this page changes the roster DIRECTORY, so the other + * surfaces reading the same roster re-read it. A settings field moving is + * already announced by the host through `settings/changed`; a directory + * copied or deleted here is not, and the new-session chip has no other + * way to learn a preset it should offer now exists. + */ + private readonly rosterChanged: () => void = () => {}, + ) {} + + private set(patch: Partial<AgentPresetSectionState>): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + private patchCopy(patch: Partial<CopyDraft>): void { + const { copy } = this.store.getSnapshot() + if (copy === null) return + this.set({ copy: { ...copy, ...patch } }) + } + + /** + * Load the roster. An empty roster means the deployment composes no + * presets, which is a valid deployment rather than a failure — the section + * reports `unavailable` and renders nothing. + * @returns once the snapshot reflects the host. + */ + async load(): Promise<void> { + const roster = await beginRosterRead(this.api, this.store) + if (roster === undefined) return + const { presets, authorable, hasDocument } = roster + if (presets.length === 0) { + // Nothing to manage leaves nothing to keep a dialog open over. + this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) + return + } + // A reveal outlives a reload but not its preset: a path for a row the + // roster no longer lists would be a claim about a directory that is gone. + const revealed = this.store.getSnapshot().revealedPaths + const kept = Object.fromEntries( + Object.entries(revealed).filter(([id]) => presets.some(preset => preset.id === id))) + this.set({ + status: 'ready', + error: null, + authorable, + hasDocument, + rows: presets.map(preset => ({ ...preset })), + revealedPaths: kept, + }) + } + + /** + * Open one shipped preset's composition in the read-only viewer. + * @param id - the preset to view. + * @returns once the composition loaded or the failure is on the page. + */ + async view(id: string): Promise<void> { + this.set({ error: null }) + try { + const response = await this.api.agentPresets.read({ agentPreset: id }) + if (!response.result.ok) { + this.set({ error: response.result.error.message }) + return + } + const { name, content } = response.result.value + this.set({ view: { id, title: name ?? id, content } }) + } catch (error) { + this.set({ error: messageOf(error) }) + } + } + + /** Close the read-only viewer. */ + closeView(): void { + this.set({ view: null }) + } + + /** + * Open the copy dialog over one preset. + * @param from - the preset the copy will start from. + */ + beginCopy(from: string): void { + const row = this.store.getSnapshot().rows.find(candidate => candidate.id === from) + this.set({ + error: null, + copy: { from, fromTitle: row?.name ?? from, id: '', name: '', saving: false, error: null }, + }) + } + + /** Close the copy dialog, discarding whatever was typed. */ + cancelCopy(): void { + this.set({ copy: null }) + } + + /** + * Name the preset the copy creates. + * @param id - the id typed into the dialog. + */ + setCopyId(id: string): void { + this.patchCopy({ id, error: null }) + } + + /** + * Name the copy's display name. + * @param name - the display name typed into the dialog. + */ + setCopyName(name: string): void { + this.patchCopy({ name, error: null }) + } + + /** + * Submit the copy, re-read the roster, then take the user to the new + * preset's files — the directory opens where the host has a desktop, and + * its path appears on the new row where it does not. + * @returns once the copy settled and the page reflects it. + */ + async confirmCopy(): Promise<void> { + const draft = this.store.getSnapshot().copy + if (draft === null || draft.saving) return + if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return + this.patchCopy({ saving: true, error: null }) + try { + const name = draft.name.trim() + const response = await this.api.agentPresets.copy({ + from: draft.from, + agentPreset: draft.id, + ...name === '' ? {} : { name }, + }) + if (!response.result.ok) { + this.patchCopy({ saving: false, error: response.result.error.message }) + return + } + this.set({ copy: null }) + await this.load() + this.rosterChanged() + // A preset is its files from here on (the dialog collected nothing + // else), so landing in them is the completion, not a follow-up. + await this.openLocation(draft.id) + } catch (error) { + this.patchCopy({ saving: false, error: messageOf(error) }) + } + } + + /** + * Open one preset's directory on the host desktop, or reveal its path on + * the row where the deployment has no opener to hand it to. + * @param id - the preset whose files the user wants. + * @returns once the host answered and the page reflects it. + */ + async openLocation(id: string): Promise<void> { + try { + const response = await this.api.agentPresets.openDocument({ agentPreset: id }) + if (!response.result.ok) { + this.set({ error: response.result.error.message }) + return + } + if (response.result.value.opened) return + const { path } = response.result.value + this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) + } catch (error) { + this.set({ error: messageOf(error) }) + } + } + + /** + * Ask for confirmation before deleting one preset. + * @param id - the preset to delete, or null to dismiss the confirmation. + */ + confirmDelete(id: string | null): void { + if (this.store.getSnapshot().deleting) return + this.set({ pendingDelete: id }) + } + + /** + * Delete the preset awaiting confirmation, then re-read the roster. + * + * A session already composed from it keeps running: its composition was + * mounted at creation and nothing re-reads the file. + * @returns once the delete settled and the page reflects it. + */ + async remove(): Promise<void> { + const { pendingDelete, deleting } = this.store.getSnapshot() + if (pendingDelete === null || deleting) return + this.set({ deleting: true, error: null }) + try { + const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete }) + if (!response.result.ok) { + this.set({ deleting: false, pendingDelete: null, error: response.result.error.message }) + return + } + this.set({ deleting: false, pendingDelete: null }) + await this.load() + this.rosterChanged() + } catch (error) { + this.set({ deleting: false, pendingDelete: null, error: messageOf(error) }) + } + } + + /** + * Make one preset the default for sessions created later. Running sessions + * keep the composition they began with, so this never disturbs work. + * @param id - the preset to make default. + * @returns once the write settled and the roster was re-read. + */ + async makeDefault(id: string): Promise<void> { + const failure = await writeDefaultPreset(this.api, id) + if (failure !== undefined) { + this.set({ error: failure }) + return + } + await this.load() + } +} diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts new file mode 100644 index 0000000000..c9499c40a9 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -0,0 +1,255 @@ +/** + * Agent-preset default-settings controller. + * + * Options and the current default both come from one `agentPreset.list` call: + * the roster already reports which id a session with no explicit choice gets, + * so the row needs no schema introspection. Writes target the settings + * namespace's `default` field, which is what the host resolves at creation. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** The agent-preset settings namespace on the host wire. */ +export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' + +/** + * Human text for a rejected wire call. A transport failure rejects with an + * Error; a host or a runtime can reject with anything, and the surface still + * has to say something. + * @param error - the rejection value. + * @returns the message to show. + */ +export function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Persist one preset as the default for sessions created later. + * + * The default is a settings field rather than a preset property, so both the + * General row and the management section write it here — one home for which + * namespace and field the host resolves at session creation. + * @param api - the settings wire face. + * @param id - the preset to make default. + * @returns the failure message, or undefined once the write landed. + */ +export async function writeDefaultPreset( + api: Pick<IApiClient, 'settings'>, + id: string, +): Promise<string | undefined> { + let response + try { + response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } }) + } catch (error) { + // The transport rejected rather than answering; the caller must be able to + // say so instead of the row silently snapping back. + return messageOf(error) + } + return response.result.ok ? undefined : response.result.error.message +} + +/** One selectable preset. */ +export interface AgentPresetOption { + /** Preset id, written to Settings and the label's fallback. */ + id: string + /** Whether the preset ships with the deployment or was authored locally. */ + trust: 'system' | 'user' + /** Display name the preset published, absent when it published none. */ + name?: string + /** One sentence on what the preset is for. */ + description?: string +} + +/** One roster entry exactly as the host reports it. */ +export interface RosterPreset { + /** Preset id and directory name. */ + id: string + /** Whether the preset ships with the deployment or was authored locally. */ + trust: 'system' | 'user' + /** Whether a session that names no preset gets this one. */ + isDefault: boolean + /** Display name the preset published, absent when it published none. */ + name?: string + /** One sentence on what the preset is for. */ + description?: string + /** Why the preset cannot compose a session, absent when it can. */ + broken?: string +} + +/** The roster the host answered with. */ +export interface RosterValue { + /** Every preset the deployment composes, in the order the host lists them. */ + presets: readonly RosterPreset[] + /** Whether this browser may author presets at all. */ + authorable: boolean + /** Whether the host can open a preset directory on a native desktop. */ + hasDocument: boolean +} + +/** The roster, or the message to show in its place. */ +export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: string } + +/** + * Read the roster, folding both refusal shapes into one message. + * + * The wire refuses in two ways — the transport rejects, or it answers an + * `ok: false` envelope — and every surface treats them identically. Folding + * them here keeps each store's `load` about what it does with a roster rather + * than about how the call can fail. + * @param api - the agent-preset wire face. + * @returns the roster, or the message to show in its place. + */ +export async function readRoster(api: Pick<IApiClient, 'agentPresets'>): Promise<RosterRead> { + try { + const response = await api.agentPresets.list({}) + return response.result.ok + ? { ok: true, value: response.result.value } + : { ok: false, error: response.result.error.message } + } catch (error) { + return { ok: false, error: messageOf(error) } + } +} + +/** + * The opening move every roster-backed surface makes: refuse a read that is + * already in flight, mark the store loading, then read. + * + * A surface that gets `undefined` returns without touching its snapshot + * further — either another read owns it, or this one already wrote the + * failure. What differs between surfaces starts after this. + * @param api - the agent-preset wire face. + * @param store - the surface's own snapshot store. + * @returns the roster, or undefined when the caller should return. + */ +export async function beginRosterRead<S extends { status: string; error: string | null }>( + api: Pick<IApiClient, 'agentPresets'>, + store: SnapshotStore<S>, +): Promise<RosterValue | undefined> { + const before = store.getSnapshot() + if (before.status === 'loading') return undefined + store.set({ ...before, status: 'loading', error: null }) + const roster = await readRoster(api) + if (roster.ok) return roster.value + store.set({ ...store.getSnapshot(), status: 'error', error: roster.error }) + return undefined +} + +/** + * The roster entries as the pickers render them: healthy presets only. + * + * The chip and the row exist to choose the NEXT session's composition, and a + * broken preset cannot compose one — offering it would defer the discovery + * of that fact to a failed session start. The management section renders the + * full roster (broken rows included) from its own store instead. + * + * The chip, the row, and the management section all show the same facts, and + * `exactOptionalPropertyTypes` makes "absent" and "present as undefined" + * different shapes — so the spread dance belongs in one place rather than + * once per store. + * @param presets - the roster the host answered with. + * @returns one option per selectable preset, in roster order. + */ +export function presetOptions( + presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string; broken?: string }[], +): AgentPresetOption[] { + return presets.filter(preset => preset.broken === undefined).map(preset => ({ + id: preset.id, + trust: preset.trust, + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + })) +} + +/** Agent-preset settings-row snapshot. */ +export interface AgentPresetSettingsState { + status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error' + error: string | null + /** + * Whether this browser may persist the choice at all. `settings.describe` is + * loopback-only and reports a read-only provider as `writable: false`; the + * row then shows the current default and disables the control rather than + * offering a write the gateway will refuse. + */ + writable: boolean + currentValue: string + options: readonly AgentPresetOption[] +} + +const INITIAL: AgentPresetSettingsState = { + status: 'idle', + error: null, + // Assumed until `load()` asks; a row that has not read yet renders nothing + // interactive anyway (status 'idle'). + writable: true, + currentValue: '', + options: [], +} + +/** Reads the roster and persists the chosen default. */ +export class AgentPresetSettingsController { + /** Row snapshot the renderer subscribes to. */ + readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL) + + constructor(private readonly api: IApiClient) {} + + private set(patch: Partial<AgentPresetSettingsState>): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + /** + * Load the roster. An empty roster means the deployment composes no + * presets, which is a valid deployment rather than a failure — the row + * reports `unavailable` and renders nothing. + * @returns once the snapshot reflects the host. + */ + async load(): Promise<void> { + const roster = await beginRosterRead(this.api, this.store) + if (roster === undefined) return + const { presets } = roster + const [first] = presets + if (first === undefined) { + this.set({ status: 'unavailable', options: [], currentValue: '' }) + return + } + try { + // The roster says what may be chosen; `settings.describe` says whether + // this browser may write the choice down. A non-loopback browser reaches + // neither method, so a refused describe leaves the row read-only rather + // than offering a control whose write answers `settings-not-exposed`. + const described = await this.api.settings.describe({}) + this.set({ + status: 'ready', + error: null, + writable: described.result.ok && described.result.value.writable, + options: presetOptions(presets), + // A roster can mark nothing default: settings can name a preset that + // was since deleted, and the picker still has to show something. + currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id, + }) + } catch (error) { + this.set({ status: 'error', error: messageOf(error) }) + } + } + + /** + * Persist one preset as the default for sessions created later. Running + * sessions keep the composition they were created with, so this never + * disturbs work in progress. + * @param id - the preset to make default. + * @returns once the write settled and the roster was re-read. + */ + async select(id: string): Promise<void> { + const before = this.store.getSnapshot() + if (before.status === 'saving' || id === before.currentValue) return + this.set({ status: 'saving', error: null, currentValue: id }) + const failure = await writeDefaultPreset(this.api, id) + if (failure !== undefined) { + this.set({ status: 'ready', currentValue: before.currentValue, error: failure }) + return + } + // Re-read rather than trust the patch: the host resolves the default + // through the same roster the row displays. + await this.load() + } +} diff --git a/packages/client/ui-agent-preset/src/css-modules.d.ts b/packages/client/ui-agent-preset/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-agent-preset/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} diff --git a/packages/client/ui-agent-preset/src/index.ts b/packages/client/ui-agent-preset/src/index.ts new file mode 100644 index 0000000000..c145962f1d --- /dev/null +++ b/packages/client/ui-agent-preset/src/index.ts @@ -0,0 +1,9 @@ +/** + * Agent-preset surface plugin, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half ships the + * General-settings row through exports["./client"], discovered from the + * package.json dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-agent-preset/src/invariant.ts b/packages/client/ui-agent-preset/src/invariant.ts new file mode 100644 index 0000000000..1794763066 --- /dev/null +++ b/packages/client/ui-agent-preset/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`. + * @module @deepseek-ai/dsh-client-ui-agent-preset/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-agent-preset-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream + * or mutable runtime data; the roster and the settings write are host contracts covered there. + */ +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/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts new file mode 100644 index 0000000000..6272501183 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -0,0 +1,546 @@ +/** + * Registration: the General row, the settings section, the new-session chip, + * and the header label all come from one apply, and each defers until the slot + * it fills has been declared. A pushed settings change refreshes the surfaces + * that are already showing, so a default set from one converges the other. + */ + +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' +import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' +import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' +import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' +import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx' +import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' +import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx' +import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' +import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' + +// The service reads its initial locale from the browser; these specs assert +// the shipped Chinese copy, so they state the browser they assume. +usePinnedBrowserLanguages('zh-CN') + +const ROSTER_ONE = { + rpcId: 'r', + result: { + ok: true as const, + value: { + presets: [{ id: 'standard', trust: 'system', isDefault: true }], + authorable: true, + hasDocument: true, + }, + }, +} + +/** The roster after this browser copied one preset of its own. */ +const ROSTER_AUTHORED = { + rpcId: 'r', + result: { + ok: true as const, + value: { + presets: [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ], + authorable: true, + hasDocument: true, + }, + }, +} + +/** The same roster with a second preset carrying the default. */ +const ROSTER_MOVED = { + rpcId: 'r', + result: { + ok: true as const, + value: { + presets: [ + { id: 'standard', trust: 'system', isDefault: false }, + { id: 'minimal', trust: 'system', isDefault: true }, + ], + authorable: true, + hasDocument: true, + }, + }, +} + +async function bench() { + const ctx = new Context() + // The host's answer, mutable so a spec can move the default the way the + // settings surface does and watch who re-reads it. + let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE + const moveDefault = (): void => { ROSTER = ROSTER_MOVED } + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + const calls: string[] = [] + ctx.provide('connection', { + api: { + agentPresets: { + list: () => { calls.push('list'); return Promise.resolve(ROSTER) }, + read: () => Promise.resolve({ + rpcId: 'r', + result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } }, + }), + copy: (payload: { from: string; agentPreset: string }) => { + calls.push(`copy:${payload.agentPreset}`) + // The host's roster now contains it, which is the whole point of the + // copy and what every surface must converge on. + ROSTER = ROSTER_AUTHORED + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) + }, + openDocument: (payload: { agentPreset: string }) => { + calls.push(`openDocument:${payload.agentPreset}`) + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } }) + }, + remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }), + select: (payload: { agentPreset: string }) => { + calls.push(`select:${payload.agentPreset}`) + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) + }, + }, + settings: { + // The row reads this to learn whether this browser may write at all. + describe: () => Promise.resolve({ + rpcId: 'r', + result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }, + }), + update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) }, + }, + }, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, calls, moveDefault } +} + +function declareRoot(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { + 'settings.general.item': { kind: 'list', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + conversation: { kind: 'single', scope: 'root' }, + }, + } as never, () => null) +} + +/** The conversation's own declarations, which the chip and label wait for. */ +function declareConversation(slots: SlotsService): () => void { + return slots.register({ + name: 'conversation', + children: { + 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) +} + +/** A workspaces double recording new-session starts. */ +function workspacesDouble() { + const starts: unknown[] = [] + return { + starts, + startSession: (workspaceId?: unknown) => { starts.push(workspaceId ?? null) }, + } +} + +/** A sessions double whose list can be moved and whose changes are pushed. */ +function sessionsDouble(state: { + current?: string + byId: Record<string, { id: string; blank: boolean; agentPreset?: string }> +}) { + const listeners = new Set<() => void>() + return { + list: { + getSnapshot: () => state, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + }, + /** Push a list change the way the runtime's store does. */ + notify: () => { for (const fn of listeners) fn() }, + } +} + +describe('ui-agent-preset apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'connection']) + }) + + it('registers the General row and the settings section', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + const row = slots.entries('settings.general.item')[0]! + expect(row.component).toBe(AgentPresetRow) + expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 }) + const section = slots.entries('settings.section')[0]! + expect(section.component).toBe(AgentPresetSection) + expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 }) + // The nav label is a locale-following thunk; owners resolve it at read time. + expect(resolveSlotLabel(section.options.label)).toBe('Agent 预设') + }) + + it('registers into a declaration that arrives after apply', async () => { + const { ctx, slots } = await bench() + await ctx.plugin({ inject: [...inject], apply }).await() + + declareRoot(slots) + + await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) + }) + + it('hands each surface its own store and actions', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + + const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + + expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection) + // Each thunk reaches its own controller: the row's load fills the row's + // store, and the section's default write does not go through the row. + await row.load() + await row.select('standard') + await section.makeDefault('standard') + expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) + expect(section.hooks.agentPresetSection.getSnapshot().rows) + .toEqual([{ id: 'standard', trust: 'system', isDefault: true }]) + }) + + it('routes the section actions to one controller', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + + await section.load() + section.beginCopy('standard') + section.cancelCopy() + section.beginCopy('standard') + section.setCopyId('mine') + section.setCopyName('我的模式') + await section.confirmCopy() + await section.view('standard') + section.closeView() + section.confirmDelete('mine') + await Promise.all([section.openLocation('mine'), section.remove()]) + + // One controller behind every action: the copy the dialog named is the + // one the roster re-read reflects, and the delete the section confirmed + // is the one its remove() sees. + expect(calls).toContain('copy:mine') + expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0) + expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2) + }) + + it('refreshes a showing surface when its namespace changes, and ignores others', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + await section.load() + const before = calls.length + + ctx.emit('settings/changed', 'agent-presets') + await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) + const afterRelevant = calls.length + + ctx.emit('settings/changed', 'llm-deepseek') + await Promise.resolve() + + // Both surfaces re-read on their own namespace; an unrelated one moves + // neither, so this rules out a blanket refresh on every settings write. + expect(calls.length).toBe(afterRelevant) + }) + + it('re-reads both surfaces when the connection comes back', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + await section.load() + const before = calls.length + + ctx.emit('connection/reset') + + // A reconnect can land on a host whose roster changed under the browser. + await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) + }) + + it('leaves the section alone until it has been opened once', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + const before = calls.length + + ctx.emit('settings/changed', 'agent-presets') + await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) + + // Only the General row reloads: a section nobody opened has nothing to + // converge, and reading the roster for it would be a wasted round trip. + expect(calls.length - before).toBe(1) + }) + + it('registers the new-session chip and the header label, and drops both on disposal', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }) + await fiber.await() + + const chip = slots.entries('conversation.hero.agentPreset')[0]! + expect(chip.component).toBe(AgentPresetSeat) + const label = slots.entries('conversation.session.header.actions')[0]! + expect(label.component).toBe(AgentPresetLabel) + expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 }) + await fiber.dispose() + expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0) + expect(slots.entries('conversation.session.header.actions')).toHaveLength(0) + expect(slots.entries('settings.section')).toHaveLength(0) + conversation() + }) + + it('moves the chip when the default changes on the settings surface', async () => { + const { ctx, slots, moveDefault } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + + const chip = slots.entries('conversation.hero.agentPreset')[0]! + const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() + await seat.load() + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') + + // The chip opens on the deployment default, and the setting it comes from + // lives on another screen: without this the next session — the very one + // the setting governs — would be composed from the previous default until + // a reload. + // An unrelated namespace moves nothing: the chip re-reads on its own + // setting, not on every settings write in the process. + moveDefault() + ctx.emit('settings/changed', 'llm-deepseek') + await Promise.resolve() + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') + + ctx.emit('settings/changed', 'agent-presets') + await vi.waitFor(() => { + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal') + }) + conversation() + }) + + it('offers a just-authored preset on the new-session chip', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + + const chip = slots.entries('conversation.hero.agentPreset')[0]! + const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() + await seat.load() + expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard']) + + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + await section.load() + section.beginCopy('standard') + section.setCopyId('mine') + section.setCopyName('我的模式') + await section.confirmCopy() + + // Authoring copies a directory rather than writing a setting, so nothing + // on the wire announces it: a preset created to be used must appear on + // the one screen that starts sessions, without a reload. + await vi.waitFor(() => { + expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine']) + }) + conversation() + }) + + it('applies the staged choice to the blank session the flow lands on', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const state: { + current?: string + byId: Record<string, { id: string; blank: boolean; agentPreset?: string }> + } = { byId: {} } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + // Picked on the hero screen, where there is no session yet. + await chip.select('minimal') + expect(calls).not.toContain('select:minimal') + + state.current = 's1' + state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' } + sessions.notify() + + // Connecting a workspace produced the session; the stage reaches it there. + await vi.waitFor(() => { expect(calls).toContain('select:minimal') }) + }) + + it('applies the stage to a session that records no preset of its own', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const sessions = sessionsDouble({ + current: 's1', + byId: { s1: { id: 's1', blank: true } }, + }) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + await chip.select('minimal') + + // A session created before the deployment composed presets records none; + // reading that as "already runs it" would drop the pick on the floor. + expect(calls).toContain('select:minimal') + }) + + it('forgets the stage once it has been spent', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const state = { + current: 's1', + byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, + } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const chip = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + await chip.load() + await chip.select('minimal') + const spent = calls.filter(call => call === 'select:minimal').length + sessions.notify() + sessions.notify() + + // Every later list movement would re-apply a stage that was not cleared, + // switching sessions the user never picked for. + await Promise.resolve() + expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent) + }) + + it('gives the header label the same roster the General row reads', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const label = (slots.entries('conversation.session.header.actions')[0]! + .inject as unknown as () => AgentPresetLabelInjected)() + const row = (slots.entries('settings.general.item')[0]! + .inject as unknown as () => AgentPresetRowInjected)() + + await label.load() + + // One roster behind both: the label resolves a name the settings row's own + // load already fetched, rather than issuing a second read per session. + expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset) + expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) + }) + + it('stages the creator preset and starts a session from the section', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) + const workspaces = workspacesDouble() + ctx.provide('workspaces', workspaces as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + const seat = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + section.startCreatorDraft?.() + + // The pick is staged on the chip's own controller — the session the + // workspace start produces is what the stage lands on — and exactly one + // new-session flow began. + expect(section.startCreatorDraft).toBeDefined() + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') + expect(workspaces.starts).toHaveLength(1) + conversation() + }) + + it('keeps the applied composition when the roster load lands late', async () => { + const { ctx, slots, calls } = await bench() + declareRoot(slots) + const conversation = declareConversation(slots) + ctx.provide('conversation', {} as never) + const state: { + current?: string + byId: Record<string, { id: string; blank: boolean; agentPreset?: string }> + } = { byId: {} } + const sessions = sessionsDouble(state) + ctx.provide('sessions', sessions as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + const seat = (slots.entries('conversation.hero.agentPreset')[0]! + .inject as unknown as () => AgentPresetSeatInjected)() + + section.startCreatorDraft?.() + state.current = 's1' + state.byId['s1'] = { id: 's1', blank: true } + sessions.notify() + await vi.waitFor(() => { expect(calls).toContain('select:cordis') }) + + // The chip mounts with the flow's session, so its roster load can land + // AFTER the stage was consumed; the session's own composition is what + // the display must keep — not the deployment default. + state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'cordis' } + await seat.load() + + expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') + conversation() + }) + + it('offers no creator draft while the conversation flow is absent', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + // No conversation scope mounted: the face omits the affordance and the + // section hides its button rather than staging into nowhere. + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + expect(section.startCreatorDraft).toBeUndefined() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx new file mode 100644 index 0000000000..7bc3d59e04 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -0,0 +1,300 @@ +// @vitest-environment jsdom +/** + * The three conversation-adjacent surfaces: the General-settings row naming the + * default for later sessions, the new-session chip naming the next one's, and + * the session header's read-only label. The split is the host's rule — a + * session's history is produced under its preset's tools, so the choice is + * only ever offered before one starts. + */ + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' +import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx' +import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' +import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx' +import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' +import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx' +import type { AgentPresetSettingsState } from '../src/client/settings-store.ts' +import type { AgentPresetSeatState } from '../src/client/seat-store.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const ROW_READY: AgentPresetSettingsState = { + status: 'ready', + error: null, + writable: true, + currentValue: 'standard', + // `mine` deliberately names itself nothing: the row must fall back to the + // id for a preset whose author wrote no metadata. + options: [{ id: 'standard', trust: 'system', name: '标准模式' }, { id: 'mine', trust: 'user' }], +} + +const SEAT_READY: AgentPresetSeatState = { + current: 'standard', + options: [ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + { id: 'mine', trust: 'user' }, + ], + busy: false, + error: null, +} + +function renderRow(state: Partial<AgentPresetSettingsState> = {}) { + const store = createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, ...state }) + const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + render(<AgentPresetRow {...({ + ...actions, + useAgentPreset: bindSnapshotSelector(store), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetRowProps)} />) + return actions +} + +function renderSeat(state: Partial<AgentPresetSeatState> = {}) { + const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state }) + const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + render(<AgentPresetSeat {...({ + ...actions, + useAgentPresetSeat: bindSnapshotSelector(store), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetSeatProps)} />) + return actions +} + +function renderLabel( + summary: { blank: boolean; agentPreset?: string } | undefined, + roster: Partial<AgentPresetSettingsState> = {}, +) { + // The chip and the label read the same roster, metadata included. + const store = createSnapshotStore<AgentPresetSettingsState>({ + ...ROW_READY, options: SEAT_READY.options, ...roster, + }) + const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } }) + const load = vi.fn(() => Promise.resolve()) + const view = render(<AgentPresetLabel {...({ + load, + sessionId: 's1', + useSessions: bindSnapshotSelector(sessions), + useAgentPresets: bindSnapshotSelector(store), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetLabelProps)} />) + return { load, view } +} + +describe('the General-settings row', () => { + it('reads the roster once and shows the current default', async () => { + const actions = renderRow() + + await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) + expect(screen.getByRole('button').textContent).toContain('标准模式') + }) + + it('marks a locally authored option as local', () => { + renderRow() + + fireEvent.click(screen.getByRole('button')) + + // A local preset is exactly as privileged as the plugins it names, so the + // list says which rows are local rather than presenting all as vetted. + expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() + // The shipped one carries no marker; only local rows are called out. + expect(screen.getAllByText('标准模式')).toHaveLength(2) + }) + + it('falls back to the id for a preset that published no name', () => { + renderRow({ + currentValue: 'mine', + options: [ + { id: 'standard', trust: 'system', name: '标准模式' }, + { id: 'bare', trust: 'system' }, + { id: 'mine', trust: 'user' }, + { id: 'ours', trust: 'user', name: '团队模式' }, + ], + }) + + // The trigger names the preset; with no metadata the id is all there is. + expect(screen.getByRole('button').textContent).toContain('mine') + + fireEvent.click(screen.getByRole('button')) + + // A locally authored preset is marked whether or not it named itself. + expect(screen.getByText(`团队模式 · ${en.userTrust}`)).toBeTruthy() + expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() + // A shipped preset with no metadata is listed by id and carries no mark. + expect(screen.getByText('bare')).toBeTruthy() + }) + + it('writes the picked preset and closes the menu', () => { + const actions = renderRow() + fireEvent.click(screen.getByRole('button')) + + fireEvent.click(screen.getByText(`mine · ${en.userTrust}`)) + + expect(actions.select).toHaveBeenCalledWith('mine') + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) + + it('closes on an outside dismissal', () => { + renderRow() + fireEvent.click(screen.getByRole('button')) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) + + it('says it is loading before the roster answers', () => { + renderRow({ status: 'loading', currentValue: '' }) + + expect(screen.getByRole('button').textContent).toContain(en.loading) + expect(screen.getByRole('button')).toHaveProperty('disabled', true) + }) + + it('shows a failure in place of the description', () => { + renderRow({ error: 'roster unavailable' }) + + expect(screen.getByRole('alert').textContent).toBe('roster unavailable') + }) + + it('renders nothing when the deployment composes no presets', () => { + const { container } = render(<AgentPresetRow {...({ + load: vi.fn(() => Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + useAgentPreset: bindSnapshotSelector( + createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, status: 'unavailable', options: [] })), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetRowProps)} />) + + expect(container.firstChild).toBeNull() + }) + + it('closes and locks the menu when the settings turn read-only', () => { + const store = createSnapshotStore<AgentPresetSettingsState>(ROW_READY) + render(<AgentPresetRow {...({ + load: vi.fn(() => Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + useAgentPreset: bindSnapshotSelector(store), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetRowProps)} />) + fireEvent.click(screen.getByRole('button')) + + act(() => { store.set({ ...ROW_READY, writable: false }) }) + + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(screen.getByRole('button')).toHaveProperty('disabled', true) + }) +}) + +describe('the new-session chip', () => { + it('reads the roster once and shows the staged preset by name', async () => { + const actions = renderSeat() + + await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) + expect(screen.getByRole('button').textContent).toContain('标准模式') + expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint) + }) + + it('offers each preset with what it is for', () => { + renderSeat() + + fireEvent.click(screen.getByRole('button')) + + // The id alone never said what a preset does; the description is the + // whole reason a preset can publish metadata at all. + expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + // A preset that published none still reads as a row, with its id standing + // in for the name. + expect(screen.getByText(en.noDescription)).toBeTruthy() + expect(screen.getByText('mine')).toBeTruthy() + }) + + it('falls back to the id when the staged preset published no name', () => { + renderSeat({ current: 'mine' }) + + expect(screen.getByRole('button').textContent).toContain('mine') + }) + + it('stages the picked preset and closes the menu', () => { + const actions = renderSeat() + fireEvent.click(screen.getByRole('button')) + + fireEvent.click(screen.getByText('mine')) + + expect(actions.select).toHaveBeenCalledWith('mine') + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) + + it('disables the trigger while a switch is in flight', () => { + renderSeat({ busy: true }) + + expect(screen.getByRole('button')).toHaveProperty('disabled', true) + }) + + it('shows a refused switch on the trigger', () => { + renderSeat({ error: 'session has already started' }) + + expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started') + }) + + it('renders nothing before the roster arrives or when there is none', () => { + const empty = renderSeat({ options: [] }) + expect(empty).toBeTruthy() + expect(screen.queryByRole('button')).toBeNull() + cleanup() + + renderSeat({ current: '' }) + expect(screen.queryByRole('button')).toBeNull() + }) + + it('closes on an outside dismissal', () => { + renderSeat() + fireEvent.click(screen.getByRole('button')) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + }) +}) + +describe('the session-header label', () => { + it('names the preset the session runs, and never offers a switch', async () => { + const { load } = renderLabel({ blank: false, agentPreset: 'standard' }) + + await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) + // A control here would promise a switch the host refuses outright. + expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式') + }) + + it('falls back to the id, and to the generic hint, when metadata is absent', () => { + renderLabel({ blank: true, agentPreset: 'mine' }) + + expect(screen.getByTitle(en.headerHint).textContent).toBe('mine') + }) + + it('shows the id until the roster resolves it', () => { + renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] }) + + // The session's own summary is the authority on which preset it runs; the + // roster only supplies the display name, and its arrival is a later frame. + expect(screen.getByTitle(en.headerHint).textContent).toBe('standard') + }) + + it('renders nothing, and reads no roster, when the session records no preset', async () => { + const absent = renderLabel({ blank: true }) + expect(absent.view.container.firstChild).toBeNull() + cleanup() + + // A session the list has not caught up to is the same answer: a deployment + // that composes no presets must not pay for a roster read per header. + const unknown = renderLabel(undefined) + expect(unknown.view.container.firstChild).toBeNull() + await act(async () => { await Promise.resolve() }) + expect(absent.load).not.toHaveBeenCalled() + expect(unknown.load).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/invariant.spec.ts b/packages/client/ui-agent-preset/tests/invariant.spec.ts new file mode 100644 index 0000000000..300e561856 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/invariant.spec.ts @@ -0,0 +1,25 @@ +/** The package's node half: an empty host body and an explained empty invariant companion. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant' + +describe('invariant companion', () => { + it('reserves package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(AgentPresetInvariant).await()).resolves.toBeDefined() + }) + + it('has an empty node half', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-agent-preset') + + // The host body exists only so the plugin appears in the host cordis.yml; + // every surface this package ships lives in the browser half. + apply() + + expect(typeof apply).toBe('function') + }) +}) diff --git a/packages/client/ui-agent-preset/tests/section-store.spec.ts b/packages/client/ui-agent-preset/tests/section-store.spec.ts new file mode 100644 index 0000000000..805a4ca8ae --- /dev/null +++ b/packages/client/ui-agent-preset/tests/section-store.spec.ts @@ -0,0 +1,580 @@ +/** + * The agent-preset management controller: a copy dialog is the only way a + * preset is created, the shipped compositions open in a read-only viewer, and + * the way into a custom preset's files is the location action — opened on a + * desktop, revealed as a path where the host has none. Every mutation + * re-reads the roster because a copy changes more than the row it targeted. + */ + +import { describe, expect, it } from 'vitest' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' +import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' + +interface FakePreset { trust: 'system' | 'user'; content: string; name?: string } +interface Recorded { method: string; payload: unknown } + +interface FakeOptions { + /** Every call the controller made, in order. */ + calls?: Recorded[] + /** Reject `list` with this message. */ + failList?: string + /** Reject `read` with this message. */ + failRead?: string + /** Reject `copy` with this message. */ + failCopy?: string + /** Reject `openDocument` with this message. */ + failOpen?: string + /** Reject `remove` with this message. */ + failRemove?: string + /** Reject `settings.update` with this message. */ + failSettings?: string + /** Throw from `list` rather than answering, as a dead transport does. */ + throwList?: boolean + /** Throw from `read`, as a dead transport does. */ + throwRead?: boolean + /** Throw from `copy`, as a dead transport does. */ + throwCopy?: boolean + /** Throw from `openDocument`, as a dead transport does. */ + throwOpen?: boolean + /** Whether the deployment configures a writable root. */ + authorable?: boolean + /** Whether the host can open a preset directory on a desktop. */ + hasDocument?: boolean + /** Hold `remove` until this resolves, to observe the in-flight state. */ + holdRemove?: Promise<void> +} + +const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } }) +const fail = (message: string) => + Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } }) + +/** + * A wire face over an in-memory preset store: copies land, so the roster the + * controller re-reads after a copy is the one the copy produced. + * @param presets - the starting compositions by id. + * @param defaultId - the preset a session with no choice gets. + * @param options - failure injection and call recording. + * @returns the fake client. + */ +function fakeApi( + presets: Map<string, FakePreset>, + defaultId: { id: string }, + options: FakeOptions = {}, +): Pick<IApiClient, 'agentPresets' | 'settings'> { + const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } + return { + agentPresets: { + list: () => { + record('list', {}) + if (options.throwList === true) return Promise.reject(new Error('socket closed')) + if (options.failList !== undefined) return fail(options.failList) + return ok({ + presets: [...presets].map(([id, preset]) => ({ + id, trust: preset.trust, isDefault: id === defaultId.id, + ...preset.name === undefined ? {} : { name: preset.name }, + })), + authorable: options.authorable ?? true, + hasDocument: options.hasDocument ?? true, + }) + }, + read: (payload: { agentPreset: string }) => { + record('read', payload) + if (options.throwRead === true) return Promise.reject(new Error('socket closed')) + if (options.failRead !== undefined) return fail(options.failRead) + const preset = presets.get(payload.agentPreset) + /* v8 ignore next -- every test reads an id the fake store holds */ + if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`) + return ok({ + agentPreset: payload.agentPreset, + trust: preset.trust, + content: preset.content, + ...preset.name === undefined ? {} : { name: preset.name }, + }) + }, + copy: (payload: { from: string; agentPreset: string; name?: string }) => { + record('copy', payload) + if (options.throwCopy === true) return Promise.reject(new Error('socket closed')) + if (options.failCopy !== undefined) return fail(options.failCopy) + const source = presets.get(payload.from) + /* v8 ignore next -- every test copies a source the fake store holds */ + if (source === undefined) return fail(`unknown preset ${payload.from}`) + presets.set(payload.agentPreset, { + trust: 'user', + content: source.content, + ...payload.name === undefined ? {} : { name: payload.name }, + }) + return ok({ agentPreset: payload.agentPreset }) + }, + openDocument: (payload: { agentPreset: string }) => { + record('openDocument', payload) + if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) + if (options.failOpen !== undefined) return fail(options.failOpen) + return (options.hasDocument ?? true) + ? ok({ opened: true }) + : ok({ opened: false, path: `/presets/${payload.agentPreset}` }) + }, + remove: async (payload: { agentPreset: string }) => { + record('remove', payload) + await options.holdRemove + if (options.failRemove !== undefined) return await fail(options.failRemove) + presets.delete(payload.agentPreset) + return await ok({}) + }, + }, + settings: { + update: (payload: { ns: string; patch: { default?: string } }) => { + record('settings.update', payload) + if (options.failSettings !== undefined) return fail(options.failSettings) + /* v8 ignore next -- the controller only ever patches `default` */ + defaultId.id = payload.patch.default ?? defaultId.id + return ok({}) + }, + }, + } as unknown as Pick<IApiClient, 'agentPresets' | 'settings'> +} + +function seed(): Map<string, FakePreset> { + return new Map<string, FakePreset>([ + ['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }], + ['mine', { trust: 'user', content: '- id: tool-read\n' }], + ]) +} + +function harness(options: FakeOptions = {}) { + const presets = seed() + const defaultId = { id: 'standard' } + const calls: Recorded[] = [] + let rosterChanges = 0 + const controller = new AgentPresetSectionController( + fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }), + () => { rosterChanges += 1 }, + ) + return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges } +} + +function copyOf(controller: AgentPresetSectionController): CopyDraft { + const { copy } = controller.store.getSnapshot() + if (copy === null) throw new Error('expected an open copy dialog') + return copy +} + +describe('loading the roster', () => { + it('maps the roster onto rows with the capability flags', async () => { + const { controller } = harness({ authorable: true, hasDocument: false }) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('ready') + expect(state.authorable).toBe(true) + expect(state.hasDocument).toBe(false) + expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine']) + expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' }) + }) + + it('reports an empty roster as unavailable, not as an error', async () => { + const { controller, presets } = harness() + presets.clear() + + await controller.load() + + expect(controller.store.getSnapshot().status).toBe('unavailable') + }) + + it('keeps one load in flight rather than stacking reads', async () => { + const { controller, calls } = harness() + + await Promise.all([controller.load(), controller.load()]) + + expect(calls.filter(call => call.method === 'list')).toHaveLength(1) + }) + + it('surfaces a refusal as the page error', async () => { + const { controller } = harness({ failList: 'not for you' }) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('error') + expect(state.error).toBe('not for you') + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller } = harness({ throwList: true }) + + await controller.load() + + expect(controller.store.getSnapshot().status).toBe('error') + expect(controller.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('the read-only viewer', () => { + it('opens a shipped composition under its display name', async () => { + const { controller } = harness() + await controller.load() + + await controller.view('standard') + + expect(controller.store.getSnapshot().view).toEqual({ + id: 'standard', title: '标准模式', content: '- id: tool-bash\n', + }) + }) + + it('falls back to the id when the preset published no name', async () => { + const { controller } = harness() + await controller.load() + + await controller.view('mine') + + expect(controller.store.getSnapshot().view?.title).toBe('mine') + }) + + it('closes without touching the list', async () => { + const { controller } = harness() + await controller.load() + await controller.view('standard') + + controller.closeView() + + expect(controller.store.getSnapshot().view).toBeNull() + expect(controller.store.getSnapshot().rows).toHaveLength(2) + }) + + it('puts a read refusal on the page rather than opening empty', async () => { + const { controller } = harness({ failRead: 'no peeking' }) + await controller.load() + + await controller.view('standard') + + expect(controller.store.getSnapshot().view).toBeNull() + expect(controller.store.getSnapshot().error).toBe('no peeking') + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller } = harness({ throwRead: true }) + await controller.load() + + await controller.view('standard') + + expect(controller.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('the copy dialog', () => { + it('opens over the source with its display name in the title', async () => { + const { controller } = harness() + await controller.load() + + controller.beginCopy('standard') + + expect(copyOf(controller)).toMatchObject({ + from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, + }) + }) + + it('falls back to the source id when it published no name', async () => { + const { controller } = harness() + await controller.load() + + controller.beginCopy('mine') + + expect(copyOf(controller).fromTitle).toBe('mine') + }) + + it('cancel discards whatever was typed', async () => { + const { controller } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('half-typed') + + controller.cancelCopy() + + expect(controller.store.getSnapshot().copy).toBeNull() + }) + + it('ignores field edits and submits with no dialog open', async () => { + const { controller, calls } = harness() + await controller.load() + + controller.setCopyId('typed-into-nothing') + controller.setCopyName('nameless') + await controller.confirmCopy() + + expect(controller.store.getSnapshot().copy).toBeNull() + expect(calls.some(call => call.method === 'copy')).toBe(false) + }) + + it('typing clears the previous failure', async () => { + const { controller } = harness({ failCopy: 'disk full' }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + await controller.confirmCopy() + expect(copyOf(controller).error).toBe('disk full') + + controller.setCopyName('renamed') + + expect(copyOf(controller).error).toBeNull() + }) +}) + +describe('the copy blocker', () => { + const rows: PresetRow[] = [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ] + const draft = (id: string): CopyDraft => + ({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null }) + + it('requires an id, a containable shape, and a free name', () => { + expect(draftBlocker(draft(''), rows)).toBe('idRequired') + expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid') + expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid') + expect(draftBlocker(draft('mine'), rows)).toBe('idTaken') + expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined() + }) +}) + +describe('submitting a copy', () => { + it('copies, re-reads the roster, announces the change, and opens the files', async () => { + const { controller, calls, rosterChanges } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + controller.setCopyName('我的模式') + + await controller.confirmCopy() + + const state = controller.store.getSnapshot() + expect(state.copy).toBeNull() + expect(state.rows.map(row => row.id)).toContain('my-copy') + expect(rosterChanges()).toBe(1) + expect(calls.find(call => call.method === 'copy')?.payload) + .toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' }) + // A preset is its files from here on, so landing in them completes the + // copy rather than following it. + expect(calls.find(call => call.method === 'openDocument')?.payload) + .toEqual({ agentPreset: 'my-copy' }) + }) + + it('omits an empty name so the copy falls back to its id', async () => { + const { controller, calls } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + controller.setCopyName(' ') + + await controller.confirmCopy() + + expect(calls.find(call => call.method === 'copy')?.payload) + .toEqual({ from: 'standard', agentPreset: 'my-copy' }) + }) + + it('reveals the new directory as text where the host has no desktop', async () => { + const { controller } = harness({ hasDocument: false }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + + await controller.confirmCopy() + + expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy') + }) + + it('keeps the dialog open with the refusal on it', async () => { + const { controller, rosterChanges } = harness({ failCopy: 'id already exists' }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + + await controller.confirmCopy() + + expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' }) + expect(rosterChanges()).toBe(0) + }) + + it('folds a dead transport into the dialog error', async () => { + const { controller } = harness({ throwCopy: true }) + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('my-copy') + + await controller.confirmCopy() + + expect(copyOf(controller).error).toContain('socket closed') + }) + + it('refuses to submit while blocked or already saving', async () => { + const { controller, calls } = harness() + await controller.load() + controller.beginCopy('standard') + controller.setCopyId('mine') + + await controller.confirmCopy() + + expect(calls.some(call => call.method === 'copy')).toBe(false) + }) +}) + +describe('the location action', () => { + it('opens the directory and leaves the page alone on a desktop host', async () => { + const { controller, calls } = harness() + await controller.load() + + await controller.openLocation('mine') + + expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' }) + expect(controller.store.getSnapshot().revealedPaths).toEqual({}) + }) + + it('reveals the path on the row where the host has none', async () => { + const { controller } = harness({ hasDocument: false }) + await controller.load() + + await controller.openLocation('mine') + + expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' }) + }) + + it('drops a revealed path once its preset leaves the roster', async () => { + const { controller, presets } = harness({ hasDocument: false }) + await controller.load() + await controller.openLocation('mine') + presets.delete('mine') + + await controller.load() + + expect(controller.store.getSnapshot().revealedPaths).toEqual({}) + }) + + it('surfaces a refusal as the page error', async () => { + const { controller } = harness({ failOpen: 'not yours' }) + await controller.load() + + await controller.openLocation('mine') + + expect(controller.store.getSnapshot().error).toBe('not yours') + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller } = harness({ throwOpen: true }) + await controller.load() + + await controller.openLocation('mine') + + expect(controller.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('deleting', () => { + it('asks first, then deletes, re-reads, and announces the change', async () => { + const { controller, rosterChanges } = harness() + await controller.load() + + controller.confirmDelete('mine') + expect(controller.store.getSnapshot().pendingDelete).toBe('mine') + await controller.remove() + + const state = controller.store.getSnapshot() + expect(state.pendingDelete).toBeNull() + expect(state.rows.map(row => row.id)).not.toContain('mine') + expect(rosterChanges()).toBe(1) + }) + + it('dismisses the confirmation without deleting', async () => { + const { controller, calls } = harness() + await controller.load() + controller.confirmDelete('mine') + + controller.confirmDelete(null) + await controller.remove() + + expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine') + expect(calls.some(call => call.method === 'remove')).toBe(false) + }) + + it('ignores a second confirmation while one delete is in flight', async () => { + let release = (): void => {} + const gate = new Promise<void>((resolve) => { release = resolve }) + const { controller, calls } = harness({ holdRemove: gate }) + await controller.load() + controller.confirmDelete('mine') + const removal = controller.remove() + + controller.confirmDelete('standard') + await controller.remove() + release() + await removal + + expect(calls.filter(call => call.method === 'remove')).toHaveLength(1) + }) + + it('surfaces a refusal and clears the confirmation', async () => { + const { controller } = harness({ failRemove: 'shipped preset' }) + await controller.load() + controller.confirmDelete('mine') + + await controller.remove() + + const state = controller.store.getSnapshot() + expect(state.error).toBe('shipped preset') + expect(state.pendingDelete).toBeNull() + expect(state.deleting).toBe(false) + }) + + it('folds a dead transport into the same error surface', async () => { + const { controller, presets } = harness() + await controller.load() + presets.clear() + const broken = new AgentPresetSectionController({ + agentPresets: { + list: () => Promise.reject(new Error('gone')), + remove: () => Promise.reject(new Error('socket closed')), + }, + settings: {}, + } as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>) + broken.confirmDelete('mine') + + await broken.remove() + + expect(broken.store.getSnapshot().error).toContain('socket closed') + }) +}) + +describe('a controller with no roster listener', () => { + it('completes a delete without anyone to notify', async () => { + // The rosterChanged callback is optional wiring, not a requirement: a + // page composed without sibling surfaces still deletes cleanly. + const presets = seed() + const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' })) + await alone.load() + alone.confirmDelete('mine') + + await alone.remove() + + expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine') + }) +}) + +describe('the default preset', () => { + it('writes the setting and re-reads the roster', async () => { + const { controller, defaultId } = harness() + await controller.load() + + await controller.makeDefault('mine') + + expect(defaultId.id).toBe('mine') + expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true) + }) + + it('surfaces a settings refusal as the page error', async () => { + const { controller } = harness({ failSettings: 'read-only settings' }) + await controller.load() + + await controller.makeDefault('mine') + + expect(controller.store.getSnapshot().error).toContain('read-only settings') + }) +}) diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx new file mode 100644 index 0000000000..36e34067b3 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +/** + * The management section's rendering rules: which actions a row offers depends + * on its trust, a shipped composition opens in a read-only viewer, creation is + * a copy dialog that collects an id and an optional name, and the location + * action follows the host's desktop capability. + */ + +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' +import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' +import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const READY: AgentPresetSectionState = { + status: 'ready', + error: null, + authorable: true, + hasDocument: true, + rows: [ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + { id: 'mine', trust: 'user', isDefault: false }, + ], + copy: null, + view: null, + pendingDelete: null, + deleting: false, + revealedPaths: {}, +} + +/** + * Render the section over a fixed snapshot, with every action a spy. + * @param state - the snapshot to render. + * @returns the spies, so a test can assert what a click reached. + */ +function renderSection( + state: Partial<AgentPresetSectionState> = {}, + options: { creator?: boolean } = {}, +) { + const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state }) + const actions = { + load: vi.fn(() => Promise.resolve()), + // The shell-owned section affordance (SettingsSectionOwnerProps.close). + close: vi.fn(), + ...options.creator === false ? {} : { startCreatorDraft: vi.fn() }, + view: vi.fn(() => Promise.resolve()), + closeView: vi.fn(), + beginCopy: vi.fn(), + cancelCopy: vi.fn(), + setCopyId: vi.fn(), + setCopyName: vi.fn(), + confirmCopy: vi.fn(() => Promise.resolve()), + openLocation: vi.fn(() => Promise.resolve()), + confirmDelete: vi.fn(), + remove: vi.fn(() => Promise.resolve()), + makeDefault: vi.fn(() => Promise.resolve()), + } + const props = { + ...actions, + useAgentPresetSection: bindSnapshotSelector(store), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetSectionProps + render(<AgentPresetSection {...props} />) + return actions +} + +/** Locate a card by the id it prints, not by its display name. */ +function rowFor(id: string): HTMLElement { + const key = screen.getAllByText(id).find(node => node.tagName === 'CODE') + const row = key?.closest('li') ?? null + /* v8 ignore next -- every rendered card prints its id */ + if (row === null) throw new Error(`no card for ${id}`) + return row +} + +describe('the preset list', () => { + it('reads the roster once when it first renders', async () => { + const actions = renderSection() + + await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) + }) + + it('shows the published name and description, falling back to the id', () => { + renderSection() + + // The name is what a picker reads; the id stays visible as the key the + // composition and the session header actually carry. + expect(screen.getByText('标准模式')).toBeTruthy() + expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + const mine = rowFor('mine') + expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0) + expect(within(mine).getByText(en.noDescription)).toBeTruthy() + }) + + it('marks trust and the one in use, and offers no "set default" on it', () => { + renderSection() + + const standard = rowFor('standard') + expect(within(standard).getByText(en.builtIn)).toBeTruthy() + expect(within(standard).getByText(en.inUse)).toBeTruthy() + expect(within(standard).queryByText(en.setDefault)).toBeNull() + expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy() + }) + + it('separates built-in presets from custom ones', () => { + renderSection() + + // Two different things: one set ships with the deployment and is + // read-only, the other is the user's own. + expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy() + }) + + it('shows no group heading for a set nobody has', () => { + renderSection({ rows: [{ id: 'standard', trust: 'system', isDefault: true }] }) + + expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull() + }) + + it('leads with the two ways a preset is created', () => { + renderSection() + + // The page has no create button: the intro is what tells a first-time + // reader that copying an existing preset — or drafting one in Creator + // mode — IS the way to make one. + expect(screen.getByText(new RegExp('Creator mode'))).toBeTruthy() + }) + + it('picks a preset by clicking its card, and the one in use is inert', () => { + const actions = renderSection() + + const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: 标准模式` }) + expect(inUse).toHaveProperty('disabled', true) + fireEvent.click(inUse) + + // Clicking the card IS the choice; the preset already in use cannot be + // re-picked, so the click reaches nothing. + expect(actions.makeDefault).not.toHaveBeenCalled() + }) + + it('offers View on a shipped row and the location on a custom one', () => { + renderSection() + + // A shipped preset is the composition a copy starts from — reading it is + // the point. A custom preset is edited in its files, so its row leads + // there instead; there is no editor for either. + const standard = rowFor('standard') + expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy() + expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull() + const mine = rowFor('mine') + expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy() + expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull() + }) + + it('offers Delete only for a locally authored preset', () => { + renderSection() + + expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy() + expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull() + }) + + it('disables duplication when nothing is writable, and says why', () => { + renderSection({ authorable: false }) + + const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` }) + expect(duplicate).toHaveProperty('disabled', true) + expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable) + }) + + it('marks a broken custom preset: unselectable, uncopyable, still deletable', () => { + const actions = renderSection({ + rows: [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', broken: 'the composition file agent.cordis.yml is missing' }, + ], + }) + + const ghost = rowFor('ghost') + // The reason is on the card, and the body cannot pick what cannot mount. + expect(within(ghost).getByText(en.brokenBadge)).toBeTruthy() + expect(within(ghost).getByRole('alert').textContent).toContain('is missing') + const body = within(ghost).getByRole('button', { name: `${en.brokenBadge}: 幽灵预设` }) + expect(body).toHaveProperty('disabled', true) + fireEvent.click(body) + expect(actions.makeDefault).not.toHaveBeenCalled() + // Copying a broken preset would only mint another broken one; deleting + // and the location remain — the files are where it gets fixed. + const duplicate = within(ghost).getByRole('button', { name: `${en.duplicate}: 幽灵预设` }) + expect(duplicate).toHaveProperty('disabled', true) + expect(duplicate.getAttribute('data-tip')).toBe(en.brokenNoCopy) + expect(within(ghost).getByRole('button', { name: `${en.delete}: 幽灵预设` })).toBeTruthy() + expect(within(ghost).getByRole('button', { name: `${en.openLocation}: 幽灵预设` })).toBeTruthy() + }) + + it('withholds the viewer on a broken shipped preset', () => { + renderSection({ + rows: [{ id: 'standard', trust: 'system', isDefault: false, name: '标准模式', broken: 'the composition is not valid YAML' }], + }) + + // There is no readable composition to offer; the reason on the card is + // the whole story a shipped row can tell. + const standard = rowFor('standard') + expect(within(standard).queryByRole('button', { name: `${en.view}: 标准模式` })).toBeNull() + expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML') + }) + + it('labels the location by what it will do without a desktop', () => { + renderSection({ hasDocument: false }) + + expect(within(rowFor('mine')).getByRole('button', { name: `${en.showLocation}: mine` })).toBeTruthy() + }) + + it('shows a revealed directory on its row', () => { + renderSection({ revealedPaths: { mine: '/home/user/.dsh/.agent-presets/mine' } }) + + const mine = rowFor('mine') + expect(within(mine).getByText('/home/user/.dsh/.agent-presets/mine')).toBeTruthy() + expect(within(mine).getByText(en.revealedPathLabel)).toBeTruthy() + // The reveal belongs to its row alone. + expect(within(rowFor('standard')).queryByText(en.revealedPathLabel)).toBeNull() + }) + + it('routes the row actions to the controller', () => { + const actions = renderSection() + + // The card body is the control that picks a preset. + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` })) + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` })) + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` })) + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` })) + + expect(actions.makeDefault).toHaveBeenCalledWith('mine') + expect(actions.openLocation).toHaveBeenCalledWith('mine') + expect(actions.beginCopy).toHaveBeenCalledWith('mine') + expect(actions.view).toHaveBeenCalledWith('standard') + }) + + it('starts a creator-mode draft session and leaves settings', () => { + const actions = renderSection({ + rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], + }) + + fireEvent.click(screen.getByRole('button', { name: en.creatorDraft })) + + expect(actions.startCreatorDraft).toHaveBeenCalledTimes(1) + // Leaving settings is part of the gesture: the flow lands in the new + // session, not behind the modal. + expect(actions.close).toHaveBeenCalledTimes(1) + }) + + it('hides the creator entry without the flow or the preset, disables it without a root', () => { + renderSection() + expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() + cleanup() + + renderSection({ + rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], + }, { creator: false }) + expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() + cleanup() + + const actions = renderSection({ + authorable: false, + rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }], + }) + const disabled = screen.getByRole('button', { name: en.creatorDraft }) + expect(disabled).toHaveProperty('disabled', true) + fireEvent.click(disabled) + expect(actions.startCreatorDraft).not.toHaveBeenCalled() + }) + + it('shows a page-level failure without hiding the list', () => { + renderSection({ error: 'settings are read-only' }) + + expect(screen.getByRole('alert').textContent).toBe('settings are read-only') + expect(rowFor('mine')).toBeTruthy() + }) + + it('renders nothing when the deployment composes no presets', () => { + const { container } = render(<AgentPresetSection {...({ + useAgentPresetSection: bindSnapshotSelector( + createSnapshotStore<AgentPresetSectionState>({ ...READY, status: 'unavailable', rows: [] })), + t: (key: keyof typeof en) => en[key], + load: vi.fn(() => Promise.resolve()), + } as unknown as AgentPresetSectionProps)} />) + + expect(container.firstChild).toBeNull() + }) + + it('offers a retry when the roster could not be read', () => { + const actions = renderSection({ status: 'error', error: 'roster unavailable' }) + + expect(screen.getByRole('alert').textContent).toContain('roster unavailable') + fireEvent.click(screen.getByText(en.retry)) + + expect(actions.load).toHaveBeenCalledTimes(2) + }) +}) + +describe('the copy dialog', () => { + const draft: CopyDraft = { + from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null, + } + + it('names its source and collects only an id and a display name', () => { + const actions = renderSection({ copy: draft }) + + const dialog = screen.getByRole('dialog') + expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`) + expect(within(dialog).getByText(en.copyIntro)).toBeTruthy() + fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } }) + fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } }) + + expect(actions.setCopyId).toHaveBeenCalledWith('my-agent') + expect(actions.setCopyName).toHaveBeenCalledWith('我的模式') + // Nothing else is collected: the description and the composition are + // edited in the preset's own files. + expect(within(dialog).queryByRole('textbox', { name: /description/i })).toBeNull() + }) + + it('creates and cancels through the controller', () => { + const actions = renderSection({ copy: { ...draft, id: 'my-agent' } }) + + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByText(en.create)) + fireEvent.click(within(dialog).getByText(en.cancel)) + + expect(actions.confirmCopy).toHaveBeenCalledTimes(1) + expect(actions.cancelCopy).toHaveBeenCalledTimes(1) + }) + + it('blocks a copy the host would refuse, and says why', () => { + const actions = renderSection({ copy: { ...draft, id: 'Upper Case' } }) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByRole('alert').textContent).toBe(en.idInvalid) + fireEvent.click(within(dialog).getByText(en.create)) + + // Disabled rather than round-tripping: the id is a directory name and the + // rule is the host's own. + expect(actions.confirmCopy).not.toHaveBeenCalled() + }) + + it('shows the host\'s refusal instead of the local blocker', () => { + renderSection({ copy: { ...draft, id: 'my-agent', error: 'already exists' } }) + + expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toBe('already exists') + }) + + it('reports a copy in flight and blocks a second click', () => { + const actions = renderSection({ copy: { ...draft, id: 'my-agent', saving: true } }) + + fireEvent.click(within(screen.getByRole('dialog')).getByText(en.creating)) + + expect(actions.confirmCopy).not.toHaveBeenCalled() + }) + + it('dismisses on Escape', () => { + const actions = renderSection({ copy: draft }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(actions.cancelCopy).toHaveBeenCalledTimes(1) + }) +}) + +describe('the read-only viewer', () => { + it('shows the composition text under the preset\'s name', () => { + renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } }) + + const dialog = screen.getByRole('dialog') + expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`) + expect(within(dialog).getByText(en.composition)).toBeTruthy() + expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n') + }) + + it('closes through the controller', () => { + const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) + + fireEvent.click(within(screen.getByRole('dialog')).getByText(en.close)) + + expect(actions.closeView).toHaveBeenCalledTimes(1) + }) + + it('dismisses on Escape', () => { + const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(actions.closeView).toHaveBeenCalledTimes(1) + }) +}) + +describe('deleting a preset', () => { + it('asks before deleting', () => { + const actions = renderSection() + + fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })) + + expect(actions.confirmDelete).toHaveBeenCalledWith('mine') + }) + + it('confirms and dismisses through the controller', () => { + const actions = renderSection({ pendingDelete: 'mine' }) + + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByText(en.deleteConfirm)) + fireEvent.click(within(dialog).getByText(en.cancel)) + + expect(actions.remove).toHaveBeenCalledTimes(1) + expect(actions.confirmDelete).toHaveBeenLastCalledWith(null) + }) + + it('dismisses the confirmation on Escape', () => { + const actions = renderSection({ pendingDelete: 'mine' }) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(actions.confirmDelete).toHaveBeenCalledWith(null) + }) + + it('reports a delete in flight', () => { + const actions = renderSection({ pendingDelete: 'mine', deleting: true }) + + fireEvent.click(within(screen.getByRole('dialog')).getByText(en.deleting)) + + expect(actions.remove).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts new file mode 100644 index 0000000000..fc36dde066 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/settings-store.spec.ts @@ -0,0 +1,458 @@ +/** + * The agent-preset settings controller: it derives both the options and the + * current default from one roster call, writes only the `default` field, and + * treats an empty roster as "this deployment composes no presets" rather than + * as a failure. + */ + +import { describe, expect, it } from 'vitest' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { + AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, +} from '../src/client/settings-store.ts' +import { AgentPresetSeatController } from '../src/client/seat-store.ts' +import type { SeatSessionSummary } from '../src/client/seat-store.ts' + +interface Recorded { ns: string; patch: unknown } + +/** A client whose roster and write outcome the test controls. */ +function fakeApi( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + options: { + writes?: Recorded[] + failWrite?: string + failList?: string + failWriteWith?: Error + readOnly?: boolean + } = {}, +): IApiClient { + return { + agentPresets: { + list: () => Promise.resolve(options.failList === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }), + }, + settings: { + // Loopback-only in production; a read-only provider answers writable:false + // and the row disables its control instead of offering a refused write. + describe: () => Promise.resolve({ + rpcId: 'r', + result: { + ok: true as const, + value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, + }, + }), + update: (payload: { ns: string; patch: unknown }) => { + options.writes?.push({ ns: payload.ns, patch: payload.patch }) + if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith) + if (options.failWrite !== undefined) { + return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } }) + } + // A committed write moves the roster's default, exactly as the host does. + for (const preset of presets) { + preset.isDefault = preset.id === (payload.patch as { default?: string }).default + } + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) + }, + }, + } as unknown as IApiClient +} + +describe('the agent-preset settings controller', () => { + it('disables the control when this browser may not write settings', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + ], { readOnly: true })) + + await controller.load() + + // `settings.describe` is loopback-only and reports a read-only provider; + // offering a control whose write answers `settings-not-exposed` would + // promise a switch the host refuses. + expect(controller.store.getSnapshot().writable).toBe(false) + expect(controller.store.getSnapshot().currentValue).toBe('standard') + }) + + it('derives options and the current default from one roster call', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ])) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('ready') + expect(state.currentValue).toBe('standard') + expect(state.options).toEqual([ + { id: 'standard', trust: 'system' }, + { id: 'mine', trust: 'user' }, + ]) + }) + + it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' }, + ] as never)) + + await controller.load() + + // A broken preset cannot compose a session; listing it here would defer + // that discovery to a failed session start. The management section shows + // (and deletes) it from its own store instead. + expect(controller.store.getSnapshot().options.map(option => option.id)).toEqual(['standard']) + }) + + it('carries the display metadata a preset published', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + ] as never)) + + await controller.load() + + // Surfaces beyond this row read the same options; the id alone never said + // what a preset does. + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + ]) + }) + + it('reports an empty roster as unavailable, not as an error', async () => { + const controller = new AgentPresetSettingsController(fakeApi([])) + + await controller.load() + + // A deployment composing no presets is valid: every session shares the + // host composition and the row renders nothing. + expect(controller.store.getSnapshot().status).toBe('unavailable') + expect(controller.store.getSnapshot().error).toBeNull() + }) + + it('writes only the default field, into the agent-presets namespace', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ], { writes })) + await controller.load() + + await controller.select('minimal') + + expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'minimal' } }]) + expect(controller.store.getSnapshot().currentValue).toBe('minimal') + }) + + it('restores the previous value and surfaces the message when the write fails', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ], { failWrite: 'read-only settings' })) + await controller.load() + + await controller.select('minimal') + + const state = controller.store.getSnapshot() + expect(state.currentValue).toBe('standard') + expect(state.error).toBe('read-only settings') + expect(state.status).toBe('ready') + }) + + it('ignores a pick that is already the default', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + ], { writes })) + await controller.load() + + await controller.select('standard') + + expect(writes).toEqual([]) + }) + + it('surfaces a roster failure without claiming the deployment has no presets', async () => { + const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' })) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('error') + expect(state.error).toBe('host down') + }) + + it('shows the first preset when the roster marks none default', async () => { + // Settings can name a preset that was since deleted; the picker still has + // to show something rather than an empty control. + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: false }, + { id: 'mine', trust: 'user', isDefault: false }, + ])) + + await controller.load() + + expect(controller.store.getSnapshot().currentValue).toBe('standard') + }) + + it('ignores a load while one is already in flight', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi( + [{ id: 'standard', trust: 'system', isDefault: true }], { writes })) + + await Promise.all([controller.load(), controller.load()]) + + expect(controller.store.getSnapshot().status).toBe('ready') + }) + + it('reads an Error\'s message and stringifies anything else', () => { + // A transport rejects with an Error, but a host or a runtime can reject + // with anything and the surface still has to say something. + expect(messageOf(new Error('boom'))).toBe('boom') + expect(messageOf({ code: 7 })).toBe('[object Object]') + }) + + it('reports a transport that rejects rather than answering', async () => { + const controller = new AgentPresetSettingsController({ + agentPresets: { list: () => Promise.reject(new Error('socket closed')) }, + } as unknown as IApiClient) + + await controller.load() + + expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) + }) + + it('reports a transport that rejects mid-write and keeps the old default showing', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ], { failWriteWith: new Error('socket closed') })) + await controller.load() + + await controller.select('mine') + + // The value snaps back because the host never took it; a picker still + // showing "mine" would be claiming a default that does not exist. + expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' }) + }) +}) + +describe('the new-session chip controller', () => { + /** A chip over a current session the test can move. */ + function chip( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + current: { id: string; blank: boolean; agentPreset?: string } | undefined, + options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {}, + ): AgentPresetSeatController { + const api = { + agentPresets: { + list: () => { + if (options.throwOn === 'list') return Promise.reject(new Error('socket closed')) + return Promise.resolve(options.failList === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }) + }, + select: (payload: { agentPreset: string }) => { + if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) + options.writes?.push({ ns: 'select', patch: payload.agentPreset }) + return Promise.resolve(options.failSelect === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } }) + }, + }, + } as unknown as IApiClient + return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined) + } + + const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ] + + it('opens on the deployment default', async () => { + const controller = chip(ROSTER, undefined) + + await controller.load() + + // The chip names the session about to start, and nothing about it is + // decided yet — the default is the honest opening value. + expect(controller.store.getSnapshot().current).toBe('standard') + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system' }, + { id: 'minimal', trust: 'system' }, + ]) + }) + + it('shows the first preset when the roster marks none default', async () => { + const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined) + + await controller.load() + + // Settings can name a preset that was since deleted; the chip still has + // to open on something rather than render nothing. + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('carries the display metadata into the menu rows', async () => { + const controller = chip([ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' }, + ] as never, undefined) + + await controller.load() + + expect(controller.store.getSnapshot().options).toEqual([ + { id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' }, + ]) + }) + + it('opens on nothing when the deployment composes no presets', async () => { + const controller = chip([], undefined) + + await controller.load() + + // An empty roster is a valid deployment: every session shares the host + // composition, and the chip renders nothing rather than an empty control. + expect(controller.store.getSnapshot().current).toBe('') + }) + + it('stages a pick made before any session exists', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, undefined, { writes }) + await controller.load() + + await controller.select('minimal') + + // Nothing to switch yet: the new-session screen precedes the session. + expect(writes).toEqual([]) + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('applies the stage to the blank session the flow lands on', async () => { + const writes: Recorded[] = [] + const current = { id: 's1', blank: true, agentPreset: 'standard' } + const controller = chip(ROSTER, current, { writes }) + await controller.load() + await controller.select('minimal') + + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('spends the stage exactly once', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + await controller.select('minimal') + + await controller.apply() + await controller.apply() + + // Every later list movement calls apply(); an unspent stage would keep + // switching sessions the user never picked for. + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + }) + + it('drops the stage against a session that already started', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes }) + await controller.load() + + await controller.select('minimal') + + // The host enforces the same rule; the chip simply never asks. + expect(writes).toEqual([]) + }) + + it('drops the stage when the session already runs it', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes }) + await controller.load() + + await controller.select('minimal') + + expect(writes).toEqual([]) + }) + + it('falls back to the default when the host refuses the switch', async () => { + const controller = chip( + ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' }) + await controller.load() + + await controller.select('minimal') + + // Showing `minimal` after a refusal would claim a composition the session + // never got. + expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' }) + }) + + it('falls back to the default when the switch never reaches the host', async () => { + const controller = chip( + ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' }) + await controller.load() + + await controller.select('minimal') + + expect(controller.store.getSnapshot()) + .toMatchObject({ current: 'standard', busy: false, error: 'socket closed' }) + }) + + it('ignores a pick while a switch is in flight', async () => { + const writes: Recorded[] = [] + const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + + const first = controller.select('minimal') + await controller.select('standard') + await first + + expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }]) + }) + + it('keeps a staged pick across a roster refresh', async () => { + const controller = chip(ROSTER, undefined) + await controller.load() + await controller.select('minimal') + + await controller.load() + + // A settings push re-reads the roster; it must not silently discard what + // the user picked for the session they are about to start. + expect(controller.store.getSnapshot().current).toBe('minimal') + }) + + it('reports a refused roster read without emptying the chip', async () => { + const controller = chip(ROSTER, undefined, { failList: 'host down' }) + + await controller.load() + + expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] }) + }) + + it('reports a transport that rejects the roster read', async () => { + const controller = chip(ROSTER, undefined, { throwOn: 'list' }) + + await controller.load() + + expect(controller.store.getSnapshot().error).toBe('socket closed') + }) + + it('reports a refused describe as a failure rather than a half-read row', async () => { + const api = { + agentPresets: { + list: () => Promise.resolve({ + rpcId: 'r', + result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } }, + }), + }, + // The roster answered; `settings.describe` is what rejected, and the row + // cannot claim a writable default it never confirmed. + settings: { describe: () => Promise.reject(new Error('socket closed')) }, + } as unknown as IApiClient + const controller = new AgentPresetSettingsController(api) + + await controller.load() + + expect(controller.store.getSnapshot().status).toBe('error') + expect(controller.store.getSnapshot().error).toBe('socket closed') + }) + + +}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json new file mode 100644 index 0000000000..2b21a7e1e2 --- /dev/null +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../test-runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts new file mode 100644 index 0000000000..3ede4df6a8 --- /dev/null +++ b/packages/client/ui-agent-preset/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6c197a5107..247afbee25 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: b57f88b5a030a6c20c957e26ea32fb125f106ab4 -README.zh.md: a8a8c4814086cad02c5416078f242ec92a7503d7 +README.md: f684f99c9e80a02e3ccad57c9a4d7246df0b192b +README.zh.md: 61c746e35d3a221d34cf9e830a63ce5d8fa0dbfd diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b57f88b5a0..f684f99c9e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -32,7 +32,7 @@ The chat flow projects consecutive model-retry nodes across retry turns into one The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a8a8c48140..61c746e35d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 401d9883a6..e5844c4761 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -152,7 +152,7 @@ export function apply(ctx: Context): void { // The per-session input machine registry (InputService face; published as // ctx.conversation.input by the service below sharing this one instance). - const inputHub = new InputHub(ctx) + const inputHub = new InputHub(ctx, t) // The composer-block registry: a plugin that knows a session cannot send — // ui-model, when no adapter serves the session's route — raises a block @@ -192,6 +192,7 @@ export function apply(ctx: Context): void { 'conversation.input.left': { kind: 'list', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, + 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 8d292abd53..fe6a14244e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -79,6 +79,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * reads the global workspace list. */ 'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } + /** + * The agent-preset chip beside the workspace picker on the new-session + * screen. Root scope: no session exists yet, so the choice is staged for + * the next one rather than applied to a current one. + */ + 'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps } // 'conversation.input.overlay' merges in ui-slash (the dependency // direction is the hard constraint — ui-slash cannot import // this package, while this package's input contract already imports @@ -141,6 +147,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } +/** Owner share of the hero agent-preset chip: the shell supplies nothing. */ +export interface HeroAgentPresetOwnerProps { + /** Marker field: the chip owns its own roster, staging, and menu state. */ + children?: never +} + +/** Owner share of the strict session content seat. */ +export interface ConversationSessionOwnerProps { + /** + * Wrap the view ring in the transcript scrollport that also hosts the + * sticky composer seat (whole `'conversation.composer'` chain output). + * Supplied for every real session (hero/settling/active) so the composer + * keeps one tree seat across the blank → active flip; the header stays + * outside that wrapper as ordinary column chrome (`flex: none`), while + * active CSS sticks the seat to the bottom of the same scrollport so wheel + * over the footer scrolls the flow. + * @param view - the session view-ring content (null while blank chrome is hidden). + * @returns the scrollport containing `view` and the sticky composer seat. + */ + wrapActiveBody?: (view: ReactNode) => ReactNode +} + /** Header actions derive their state from the standard session/global kit. */ export interface ConversationHeaderActionOwnerProps {} @@ -430,6 +458,7 @@ export type ConversationSlotProps = | 'conversation.input.dock' | 'conversation.composer.dock' | 'conversation.input.left' | 'conversation.input.right' | 'conversation.hero.workspace' + | 'conversation.hero.agentPreset' > & InjectFace<ConversationInjected> & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index ebc63b28e6..9524da7f48 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -88,6 +88,12 @@ export interface ComposerKeyboard { setDraft(text: string, editRange?: EditRange): void /** Submit with an explicit delivery mode resolved by the keyboard policy. */ submit(mode: InputSubmitMode): void + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture; the queue dock's per-row steer + * button is the same operation applied to the whole queue). + */ + steerQueue(): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index cf07101e56..96226e5b04 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -39,6 +39,11 @@ export interface SessionInputDeps { popup?: (() => PopupDismissFace | undefined) | undefined /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order (the empty-draft accelerated-Enter gesture); absent = unsupported. + */ + steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink(text: string, mode: InputSubmitMode): void } @@ -173,6 +178,16 @@ export class SessionInputShell implements SessionInput { return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass' } + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture). Execution belongs to the hub's + * queue choreography; absent dep = the gesture falls back to the machine's + * empty-draft no-op. + */ + steerQueue(): void { + this.deps.steerQueue?.() + } + /** * Space adjudication over the controller's hot state. * @returns true = a claim/insert was applied — the caller preventDefaults. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 4f6d62d662..5c36dbf4c3 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -10,6 +10,7 @@ */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -25,8 +26,14 @@ interface CommandFace { export class InputHub implements InputService { private readonly shells = new Map<SessionId, SessionInputShell>() - /** @param ctx - client root context (services resolved lazily per call — boot order stays free). */ - constructor(private readonly rootCtx: ClientContext) {} + /** + * @param ctx - client root context (services resolved lazily per call — boot order stays free). + * @param t - conversation-namespace translate thunk (reads the active locale at call time). + */ + constructor( + private readonly rootCtx: ClientContext, + private readonly t: TranslateNS<'conversation'>, + ) {} /** * Resolve the facade for one session-scope ctx (InputService face). @@ -58,6 +65,7 @@ export class InputHub implements InputService { popup: () => this.popup(actx), queue: queueReadFaceOf(session), defaultSink: (text, mode) => { this.sink(session, text, mode) }, + steerQueue: () => { void this.steerQueue(session, shell) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -139,6 +147,30 @@ export class InputHub implements InputService { ) } + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order — the same strict-steer operation as the queue dock's per-row + * button. A turn closing mid-way (`steer-unavailable`) or a row already + * claimed by the agent (`queue-item-not-found`) converges silently, while a + * genuine failure surfaces as one composer notice. Repeated triggers + * (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found` + * convergence: the snapshot may still list a row the host already steered, + * and the duplicate strict steer is a silent no-op. + * @param session - the addressed host session. + * @param shell - the resident shell (notice outlet). + */ + private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise<void> { + const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued') + if (queued.length === 0) return + for (const item of queued) { + const result = await session.updateQueue(item.id, { kind: 'steer' }) + if (result.ok) continue + if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return + shell.notify('error', this.t('queue.steerFailed')) + return + } + } + private controller(actx: ClientContext): SlashController | undefined { const slash = this.rootCtx.get('slash') return slash?.sessionOf(actx) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index a67b816012..7e3c2631e6 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,6 +23,7 @@ export const zh = { 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -166,6 +167,7 @@ export const en = { 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'context.aria': '{percent} of context used', 'context.used': 'of context used', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index f9c2eb0968..10f757d15e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -119,6 +119,7 @@ export function ConversationRoot({ }, onClose: () => { setPickerOpen(false) }, })} + {renderSlot('conversation.hero.agentPreset', {})} </div> ) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 304e5f93e7..9044f9dbeb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -99,6 +99,8 @@ export function InputBar({ // be disabled do lock it — there is no session to choose a model for. const modelSeatLocked = removed || inert || !live const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null + && input.queue.some(row => row.placement === 'queued') // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does @@ -257,9 +259,19 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return + const accelerated = e.ctrlKey || e.metaKey + // Empty-draft accelerated Enter acts on the queue instead of the (empty) + // draft: the machine rejects empty drafts, so the gesture steers every + // still-pending queued message into the running turn (the dock's per-row + // steer button applied to the whole queue). Steering needs the same + // window as the per-row button: a running ordinary session. + if (accelerated && canSteerQueue) { + keyboard.steerQueue() + return + } keyboard.submit(resolveSubmitMode( running, - e.ctrlKey || e.metaKey ? 'accelerated' : 'enter', + accelerated ? 'accelerated' : 'enter', subagent === null, )) } @@ -489,7 +501,12 @@ export function InputBar({ ? t('placeholder.parentOffline') : disabled ? t('placeholder.unavailable') - : planActive ? t('placeholder.plan') : t('placeholder.default'))} + // The steer hint deliberately outranks the plan placeholder: + // while it shows, the whole-queue gesture is genuinely available + // (the gate never consults plan mode), so the actionable hint wins. + : canSteerQueue + ? t('placeholder.steerQueue') + : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} onKeyDown={onKeyDown} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 41ffd329b8..9a3e5e2908 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,9 +84,11 @@ describe('apply wiring', () => { expect(conversationHeader?.store).toBe(conversationSession?.store) expect(details?.store).toBe(conversationSession?.store) expect(chatView?.store).toBe(conversationSession?.store) - // The hero workspace picker hole rides the conversation entry's children - // declaration (the empty-state occupant is gone). + // The hero holes ride the conversation entry's children declaration (the + // empty-state occupant is gone). Both are root-scoped: the new-session + // screen precedes the session either would belong to. expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter']) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index aaed8153da..2c1cf33ad7 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -57,6 +57,10 @@ interface BenchOptions { subagent?: Exclude<ConversationSnapshot['subagent'], null> disabled?: boolean promptError?: ConversationSnapshot['promptError'] + /** Authoritative queue rows served to the machine overlay (empty = none). */ + queue?: ConversationSnapshot['queue'] + /** The hub's steer-all face (empty-draft accelerated Enter). */ + steerQueue?: () => void variant?: 'hero' | 'composer' placeholder?: string t?: InputBarProps['t'] @@ -70,14 +74,34 @@ interface BenchOptions { toggleCommandMenu?: (selection: { start: number; end: number }) => void } +/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ +function row(id: string): ConversationSnapshot['queue'][number] { + return { + id: id as never, messageId: `message-${id}` as never, placement: 'queued', + content: [{ type: 'text', text: id }], preview: id, text: id, + } +} + /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { const sink = vi.fn() const lex = over?.lexicon + const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({ + running: over?.running ?? false, + subagent: over?.subagent ?? null, + removed: over?.disabled ?? false, + promptError: over?.promptError ?? null, + queue: over?.queue ?? [], + })) type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0] const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + queue: { + getSnapshot: () => session.getSnapshot().queue, + subscribe: fn => session.subscribe(fn), + }, + ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}), // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined @@ -89,12 +113,6 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({ - running: over?.running ?? false, - subagent: over?.subagent ?? null, - removed: over?.disabled ?? false, - promptError: over?.promptError ?? null, - })) const stop = vi.fn() const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] @@ -151,10 +169,62 @@ function bench(over?: BenchOptions) { const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]') return { view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, + steerQueue: over?.steerQueue, } } describe('Enter semantics', () => { + it('advertises the empty-draft whole-queue steering gesture when it is available', () => { + const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() }) + expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => { + expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + subagent: { + address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }, + }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + placeholder: '上层指定提示', + }).textarea.placeholder).toBe('上层指定提示') + // The command menu owns Enter while open: neither the hint nor the + // gesture may claim the chord. + expect(bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + }).textarea.placeholder).toBe('给智能体发消息') + // The steer hint intentionally outranks the plan placeholder: while it + // shows, the whole-queue gesture is genuinely available in plan mode. + expect(bench({ + running: true, + queue: [row('q-1')], + plan: { active: true, pending: false }, + }).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('an open command menu withholds the whole-queue steering gesture', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + steerQueue, + }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(steerQueue).not.toHaveBeenCalled() + expect(sink).not.toHaveBeenCalled() + }) + it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -194,6 +264,78 @@ describe('Enter semantics', () => { expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') }) + it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { + const steerQueue = vi.fn() + const queue = [row('q-1'), row('q-2')] + const meta = bench({ running: true, queue, steerQueue }) + fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) + expect(meta.steerQueue).toHaveBeenCalledTimes(1) + expect(meta.sink).not.toHaveBeenCalled() + + const ctrl = bench({ running: true, queue, steerQueue: vi.fn() }) + fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(ctrl.steerQueue).toHaveBeenCalledTimes(1) + expect(ctrl.sink).not.toHaveBeenCalled() + }) + + it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => { + // Idle: the gesture falls through to the machine's empty-draft no-op. + const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) + expect(idle.steerQueue).not.toHaveBeenCalled() + expect(idle.sink).not.toHaveBeenCalled() + + // Plain Enter never steers the queue, even under the busy Steer preference. + const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(plain.textarea, { key: 'Enter' }) + expect(plain.steerQueue).not.toHaveBeenCalled() + expect(plain.sink).not.toHaveBeenCalled() + + // Subagent sessions keep the queue transport (no steering face). + const subagent = { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'continuable' as const, + }, + parentAvailable: true, + } + const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true }) + expect(child.steerQueue).not.toHaveBeenCalled() + expect(child.sink).not.toHaveBeenCalled() + + // No queued rows: the empty draft stays a no-op. + const none = bench({ running: true, steerQueue: vi.fn() }) + fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true }) + expect(none.steerQueue).not.toHaveBeenCalled() + expect(none.sink).not.toHaveBeenCalled() + + // Pending steering rows are not the queue: nothing to flush. + const steering = bench({ + running: true, + queue: [{ ...row('s-1'), placement: 'steering' }], + steerQueue: vi.fn(), + }) + fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true }) + expect(steering.steerQueue).not.toHaveBeenCalled() + expect(steering.sink).not.toHaveBeenCalled() + }) + + it('draft content outranks the queue: accelerated Enter steers the draft only', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) + expect(sink).toHaveBeenCalledWith('插话', 'steer') + expect(steerQueue).not.toHaveBeenCalled() + }) + + it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => { + const { textarea, sink } = bench({ running: true, queue: [row('q-1')] }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(sink).not.toHaveBeenCalled() + }) + it('platform undo/redo chords route to the machine, never the browser stack', () => { const { textarea, shell } = bench({ draft: '' }) fireEvent.change(textarea, { target: { value: 'first' } }) @@ -721,13 +863,15 @@ describe('strips and variants', () => { }) describe('command launcher chrome and control seats', () => { - it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('命令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() - // Both seats dispatched, nothing rendered. - expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) + // Every seat dispatched, nothing rendered. + expect(slotCalls.map(c => c.key)).toEqual([ + 'conversation.input.plan', 'conversation.input.model', + ]) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 0dd742a5fe..894c58c4ca 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -6,9 +6,12 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' +import { zh } from '../src/client/locales.ts' async function bench() { const runtime = await SlotTestRuntime.create() @@ -22,14 +25,16 @@ async function bench() { }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. + const hub = new InputHub(runtime.ctx, makeTranslate(zh, {})) const fiber = runtime.ctx.plugin(ConversationService, { - input: new InputHub(runtime.ctx), + input: hub, blocks: new ComposerBlockRegistry(), }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder } + const shell = hub.shellFor(runtime.sessions.binding('s1')!) + return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { @@ -87,10 +92,88 @@ describe('ConversationService', () => { // No SessionsService at all: a bare context (the runtime always provides one). const bare = new Context() await bare.plugin(ConversationService, { - input: new InputHub(bare), + input: new InputHub(bare, makeTranslate(zh, {})), blocks: new ComposerBlockRegistry(), }).await() const orphan = bare.get('conversation') as ConversationService await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) }) + +describe('InputHub queue steering (empty-draft accelerated Enter)', () => { + const row = (id: string): QueuedMessage => ({ + id: id as never, + messageId: `message-${id}` as never, + placement: 'queued', + content: [{ type: 'text', text: id }], + preview: id, + text: id, + }) + + it('steers every queued row in FIFO order and leaves steering rows alone', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] + }) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.updateQueue).toHaveBeenCalledTimes(2) + }) + expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' }) + expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('converges silently when the turn closes or a row is claimed mid-steer', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + // The turn closes before the second row: the flush stops, silently. + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) + expect(b.shell.notices.getSnapshot()).toBeNull() + + // A row the host already claimed (e.g. a repeated empty-draft chord): + // the duplicate strict steer is a silent no-op. + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-3')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('surfaces one notice on a genuine steer failure and stops', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'internal', message: 'broken', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.shell.notices.getSnapshot()).toEqual( + expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }), + ) + }) + expect(b.updateQueue).toHaveBeenCalledTimes(1) + await b.runtime.dispose() + }) + + it('no-ops without queued rows', async () => { + const b = await bench() + b.shell.steerQueue() + expect(b.updateQueue).not.toHaveBeenCalled() + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index bbabbaeb0c..e7c9729685 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -453,6 +453,9 @@ describe('ConversationRoot resident composer', () => { const chip = b.view.getByRole('button', { name: '选择工作区' }) expect((chip as HTMLButtonElement).disabled).toBe(false) expect(b.slotCalls).toContain('conversation.hero.workspace') + // The agent-preset chip sits in the same row, for the same reason: both + // choices are only open before the first message. + expect(b.slotCalls).toContain('conversation.hero.agentPreset') }) it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => { diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 5b99a0e71c..02f4913751 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -584,6 +584,17 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) => </svg> ) +/** + * folder_open_16, outline layer only: the duotone original above reads a rung + * heavier than the …Outline16 family, so an icon-button row mixing them looks + * mismatched — this is the same geometry without the 20%-opacity inner fill. + */ +export const IconFolderOpenOutline16 = ({ size = 16, className }: IconProps) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"> + <path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/> + </svg> +) + /** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => ( <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"> diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 5c2fe88608..fd15671b73 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 17 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(66) + it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(67) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index bca51d908b..a00cd9bb55 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/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-question/README.md -README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c -README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691 +README.md: d31ceb62c46cb7a720b52d9e2a6c92e98d1c7e42 +README.zh.md: 9f9ad01c3f1f661f60fe11ec072f487cb18c170a diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 72d9439677..d31ceb62c4 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. +Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets). The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 6344327d26..9f9ad01c3f 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 +Web 提问功能插件:其浏览器侧把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent,无论它由哪个 preset 组装,于是一个"两工具"的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。 组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 7416596284..7e129c9731 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -40,7 +40,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "clsx": "^2.0.0", "react": "^18.2.0" }, diff --git a/packages/client/ui-question/src/index.ts b/packages/client/ui-question/src/index.ts index 901e832c14..4ceb9e0bd1 100644 --- a/packages/client/ui-question/src/index.ts +++ b/packages/client/ui-question/src/index.ts @@ -1,17 +1,14 @@ /** - * Web question plugin, node half: enabling this UI feature also exposes the - * model-facing ask_user_question tool on the host composition. + * Web question plugin, node half. + * + * Deliberately empty. Mounting `ask_user_question` here put it in the tools + * registry's GLOBAL layer, so every agent saw it no matter which preset + * composed it — a two-tool benchmark preset actually presented three, and a + * locally authored `bash-only` preset presented two. Rendering a question is + * a host UI capability; having the tool is an agent capability, and only a + * preset decides that. The `tool-ask-user` row belongs in the presets that + * want it (and in the TUI composition, which has no presets). */ -import type { Context } from 'cordis' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -/** Host services required by the model-facing tool. */ -export const inject = ['tools', 'userInteraction'] - -/** - * Mount ask_user_question for hosts that selected the Web question plugin. - * @param ctx - Host plugin context carrying tools and userInteraction. - */ -export function apply(ctx: Context): void { - toolAskUser.apply(ctx) -} +/** Host plugin body — the model-facing tool is composed per preset, not here. */ +export function apply(): void {} diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.spec.ts index 9bc34e9599..4602ef0bed 100644 --- a/packages/client/ui-question/tests/node-plugin.spec.ts +++ b/packages/client/ui-question/tests/node-plugin.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest' import ToolRegistry from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { apply, inject } from '../src/index.ts' +import { apply } from '../src/index.ts' let ctx: Context | undefined @@ -13,16 +13,19 @@ afterEach(async () => { }) describe('ui-question node plugin', () => { - it('exposes ask_user_question only for the selected Web feature lifecycle', async () => { + it('mounts no model-facing tool', async () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(UserInteractionService) - const feature = ctx.plugin({ inject: [...inject], apply }) - await feature.await() - expect(ctx.tools.get('ask_user_question')).toBeDefined() - await feature.dispose() + await ctx.plugin({ apply }).await() + + // Selecting the Web question FEATURE must not hand every agent the tool. + // `ctx.tools.register` on an unscoped host context files into the global + // layer, which merges into every agent's view regardless of the preset + // that composed it — so a two-tool benchmark preset would really present + // three. The `tool-ask-user` row belongs to the presets that want it. expect(ctx.tools.get('ask_user_question')).toBeUndefined() }) }) diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index 1b920ce207..a6400f7c84 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../ui-slots" }, - { - "path": "../../interaction/tool-ask-user" - }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 447dd7e9c7..874bd44630 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -46,7 +46,7 @@ describe('GeneralSection', () => { const renderSlot = vi.fn( ((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'], ) - const props: GeneralSectionComponentProps = { ...kit, renderSlot } + const props: GeneralSectionComponentProps = { ...kit, renderSlot, close: vi.fn() } const view = render(<GeneralSection {...props} />) return { view, renderSlot } } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index e70558081a..9163e68ba8 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -63,15 +63,18 @@ } /* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects - match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */ + match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800 wide. + One height for every section, taken from the viewport rather than the + content: sections differ by hundreds of pixels (a settings list against the + composition editor), and a content-sized panel would resize under the + pointer on every nav click. Whatever does not fit scrolls in `.options`. */ .panel { position: relative; z-index: 1; display: flex; width: 800px; - height: 600px; + height: min(800px, calc(100vh - 48px)); max-width: calc(100vw - 48px); - max-height: calc(100vh - 48px); border-radius: 24px; overflow: hidden; background: var(--dsw-alias-bg-layer-2); diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index d6b2e8ef5a..54e0e0dbb7 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -13,13 +13,16 @@ */ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' -import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} /> + if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} /> return <IconSettingsOutline16 className={css.navIcon} size={16} /> } @@ -84,7 +87,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP </button> </div> <div className={css.options}> - {active !== undefined && renderSlot('settings.section', {}, { only: active })} + {active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })} </div> </div> </div> diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index aeaafe8232..4158ee6c66 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -83,12 +83,14 @@ export interface SettingsHeaderOwnerProps { /** * Owner share of a settings section entry. The shell owns modal visibility - * and navigation; sections receive nothing but the render site (their data - * arrives through their own inject faces and stores). + * and navigation; a section's data arrives through its own inject faces and + * stores. `close` is the one shell affordance a section receives, for flows + * that leave settings altogether (starting a session from a section) — the + * onboarding coordinator's `openSection`/`complete` precedent, inverted. */ export interface SettingsSectionOwnerProps { - /** Marker field: section owner props are intentionally empty. */ - children?: never + /** Close the settings panel (the shell owns the open state). */ + close: () => void } /** Owner share of the currently active settings-backed onboarding step. */ diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 40b4dc29c2..1f34a47cf5 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -24,6 +24,7 @@ function mount({ rows = [ { id: 'general', order: 0, label: 'General' }, { id: 'models', order: 10, label: 'Models' }, + { id: 'agent-presets', order: 20, label: 'Agent presets' }, ], steps = [ { id: 'welcome', order: -100 }, diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c9d9e0b69c..e099db50fc 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee -README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c +README.md: d8e88cb7b0215b06cd55a4ee9a7932ef180f572f +README.zh.md: 073a41cac95aeb96b658b075011d8212684f1c65 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 677ac215d2..d8e88cb7b0 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. +A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 8f1f69b26a..073a41cac9 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 524cd180cc..2a2d67744e 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -8,7 +8,7 @@ * determinism * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a * leading `/name` naming a user-invocable skill and injects the rendered - * body for every front end, including `disable-model-invocation` skills the + * body for every entry point, including `disable-model-invocation` skills the * model-side catalog never lists (issue #1470). The RPC rides the plugin's * root-context connection captured at registration — the source never reads * services off a per-call argument. Draft chip visuals derive from @@ -167,7 +167,7 @@ export function apply(ctx: ClientContext): void { // lands plain text and the prompt ships the same // literal. Determinism lives host-side — the host's // pre-step boundary (dsh-tool-skill) recognizes the leading /name and - // injects the rendered body for every front end. A name shared with a + // injects the rendered body for every entry point. A name shared with a // host command still resolves to the command: adjudication claims the // line client-side before it ever becomes a prompt. return { text: `/${candidate.name} ` } diff --git a/packages/core/README.i18n.yaml b/packages/core/README.i18n.yaml index 4d9ab302d8..44e417c057 100644 --- a/packages/core/README.i18n.yaml +++ b/packages/core/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/core/README.md -README.md: 8349371ab565f2e9e735cd959026936c7ec44081 +README.md: 504686f8563f073fc8261c88275a5cdd172dd060 README.zh.md: e19c446584cfac75cb50833fa01586688b9c7c92 diff --git a/packages/core/README.md b/packages/core/README.md index 8349371ab5..504686f856 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,10 +11,10 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, deploy | [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` | | [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` | | [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` | -| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent front doors | `ctx.agentDefaultModel` | +| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent entry points | `ctx.agentDefaultModel` | | [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` | -`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent front door uses only when a session has no selection of its own. +`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent entry point uses only when a session has no selection of its own. Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces. diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 92c6095788..7835a159bc 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md -README.md: 02bcc9be3adee2293a20b3ae87ddaf4d52e70deb +README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 02bcc9be3a..98bc7d082e 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when a front door creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct front doors such as `dsh run` and Host-backed front doors such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. @@ -13,7 +13,7 @@ The service does not validate catalog membership. A provider route may serve an ## Model Experience -Indirectly, through the provider/model selection supplied to a front door; request assembly and adapters own the model-visible request. +Indirectly, through the provider/model selection supplied to an entry point; request assembly and adapters own the model-visible request. #### KV Cache effect @@ -21,5 +21,5 @@ Changing the default affects only Agents that subsequently resolve from it. An e ## Known Limitations and Deferred Work -- The service owns one process-wide default; per-session selection remains the front door's responsibility. +- The service owns one process-wide default; per-session selection remains the entry point's responsibility. - Without a settings provider, `saveSelection()` cannot retain a selection for a later Agent. diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index d012fbea93..0035b0b617 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-default-model", - "description": "Default model selection shared by Agent front doors", + "description": "Default model selection shared by Agent entry points", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 36b3b9ba44..4d09b86eb3 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -92,7 +92,7 @@ export class AgentDefaultModelService extends Service { /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> { diff --git a/packages/core/agent-tool-mode/README.i18n.yaml b/packages/core/agent-tool-mode/README.i18n.yaml new file mode 100644 index 0000000000..0799e0e547 --- /dev/null +++ b/packages/core/agent-tool-mode/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/core/agent-tool-mode/README.md +README.md: 0ef7f32c0890e5ef1071368571bd78b400b656e2 +README.zh.md: 974fc4ed574e44244f8d97682e2451440c8267ce diff --git a/packages/core/agent-tool-mode/README.md b/packages/core/agent-tool-mode/README.md new file mode 100644 index 0000000000..0ef7f32c08 --- /dev/null +++ b/packages/core/agent-tool-mode/README.md @@ -0,0 +1,31 @@ +# dsh-agent-tool-mode + +English | [中文](README.zh.md) + +The row an [agent preset](../../preset/agent-presets/README.md) carries to say which form of its tools the model sees: `native` (every schema), `code` (only `run_code` plus a generated TypeScript SDK), or `both`. + +## Why a row rather than a registry + +The tool registry cannot move into a preset. Its consumers are all host-plane — [`dsh-agent-loop`](../agent-loop/README.md) reads its scheduler, [`dsh-apiproxy`](../../host/apiproxy/README.md) reads its presenters to render tool cards, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. + +What a preset can own is the **presentation** of that registry. `ctx.tools.presentAs()` declares it for the mounting agent alone, so a Code Mode session runs beside native ones in one process, each seeing its own catalog. The deployment's `mode` on the [`dsh-tools`](../tools/README.md) row remains the default that agents declaring nothing get. + +## What it does + +`native` applies immediately. A code mode instead waits for `ctx.codeRuntime`, which is a host-plane service ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)): a preset selecting Code Mode against a deployment composing no runtime then holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. The alternative — applying optimistically — moves the failure to the session's first request, where the operator can act on neither the preset nor the composition. + +`mode` is required rather than defaulted, because a preset without this row already gets the deployment default; an omitted value would mean the row was composed for nothing. + +One agent declares one presentation. A second declaration in the same composition is refused rather than merged: two answers to "which form does the model see" is a contradiction, not an override. + +## Model Experience + +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. + +#### KV Cache effect + +No direct invalidation; the presentation is fixed when the agent is composed, so its request prefix is stable for the session's life. + +## Known Limitations and Deferred Work + +- **The runtime stays host-plane** — a preset can select Code Mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no code-mode preset. diff --git a/packages/core/agent-tool-mode/README.zh.md b/packages/core/agent-tool-mode/README.zh.md new file mode 100644 index 0000000000..974fc4ed57 --- /dev/null +++ b/packages/core/agent-tool-mode/README.zh.md @@ -0,0 +1,31 @@ +# dsh-agent-tool-mode + +[English](README.md) | 中文 + +[agent preset](../../preset/agent-presets/README.md) 用来声明「模型看到的工具是哪一种形态」的那一行:`native`(全部 schema)、`code`(只有 `run_code` 加一份生成的 TypeScript SDK)或 `both`。 + +## 为什么是一行插件,而不是把注册表搬下来 + +工具注册表搬不进 preset。它的消费者全在宿主平面——[`dsh-agent-loop`](../agent-loop/README.md) 读它的调度器,[`dsh-apiproxy`](../../host/apiproxy/README.md) 读它的 presenter 来渲染工具卡,每个工具插件都往里注册——而一个服务只有在**所有**消费者一起下沉时才能下沉。 + +preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs()` 只为正在挂载的那个 agent 声明,于是一个 Code Mode 会话可以和多个 native 会话同进程并存,各自看到各自的清单。[`dsh-tools`](../tools/README.md) 那一行上的 `mode` 仍然是默认值,供未作声明的 agent 使用。 + +## 它做什么 + +`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)):若某个 preset 在未组装运行时的部署上选择 Code Mode,本行就停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。另一种做法——先乐观应用——会把失败推迟到该会话的第一次请求,那时操作者对 preset 和组装都已无从下手。 + +`mode` 是必填而非有默认值:不带这一行的 preset 本来就会拿到部署默认值,省略它等于这一行白组装了。 + +一个 agent 只声明一次呈现方式。同一份组装里的第二次声明会被拒绝而不是合并:对「模型看到哪种形态」给出两个答案是矛盾,不是覆盖。 + +## Model Experience + +Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema. + +#### KV Cache effect + +没有直接的失效影响;呈现方式在 agent 组装时即固定,因此其请求前缀在该会话的整个生命周期内保持稳定。 + +## Known Limitations and Deferred Work + +- **运行时仍在宿主平面** —— preset 可以选择 Code Mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 code 模式的 preset。 diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json new file mode 100644 index 0000000000..236c9e5891 --- /dev/null +++ b/packages/core/agent-tool-mode/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-agent-tool-mode", + "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", + "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" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/core/agent-tool-mode/src/index.ts b/packages/core/agent-tool-mode/src/index.ts new file mode 100644 index 0000000000..d2f1e8fd49 --- /dev/null +++ b/packages/core/agent-tool-mode/src/index.ts @@ -0,0 +1,70 @@ +/** + * Agent-plane presentation selector: the row an agent preset carries to say + * which form of its tools the model sees. + * + * The tool registry itself stays on the host plane — the agent loop's + * scheduler, the API proxy's presenters, and every tool plugin are all its + * consumers, so it cannot move into a preset. What a preset CAN own is the + * presentation: `ctx.tools.presentAs()` declares it for the mounting agent + * alone, so a Code Mode agent runs beside native ones in one process. + * + * A code mode needs a TypeScript code runtime, which is a host-plane service + * ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)). + * This row therefore waits for it rather than assuming it: a preset selecting + * Code Mode against a deployment that composes no runtime fails at mount, named + * in the preset's own activation audit, instead of at the first prompt. + * @module @deepseek-ai/dsh-agent-tool-mode + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools' +// Type-only: brings the `ctx.tools` Context merge into this program. +import type {} from '@deepseek-ai/dsh-tools' + +/** Cordis plugin name. */ +export const name = 'tool-mode' + +/** + * Required services. `codeRuntime` is NOT listed: a `native` row must mount in + * a deployment that composes no runtime, and the mode-dependent wait is + * declared inside {@link apply} instead. + */ +export const inject = ['tools'] + +/** Plugin config. */ +export interface Config { + /** + * The form this agent's model sees. `native` sends every visible schema, + * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * Required rather than defaulted: the deployment default is what a preset + * without this row already gets, so an omitted value would mean the row was + * composed for nothing. + */ + mode: ToolPresentationMode +} + +/** Runtime schema. */ +export const Config: z<Config> = z.object({ + mode: z.union(['native', 'code', 'both'] as const).required(), +}) + +/** + * Declare this agent's tool presentation. + * @param ctx - the mounting agent's scope context. + * @param config - the selected presentation. + */ +export function apply(ctx: Context, config: Config): void { + // `presentAs` is itself the effect — it registers through the calling + // context and hands back that exact disposer — so the declaration unwinds + // with this row without a second wrapper owning it. + if (config.mode === 'native') { + ctx.tools.presentAs('native') + return + } + // The wait is the loud failure: an entry still pending on `codeRuntime` is + // what `dsh-agent-presets` reports as an unusable row, naming this id. + ctx.inject(['codeRuntime'], (runtimeCtx: Context) => { + runtimeCtx.tools.presentAs(config.mode) + }) +} diff --git a/packages/core/agent-tool-mode/src/invariant.ts b/packages/core/agent-tool-mode/src/invariant.ts new file mode 100644 index 0000000000..bd576cb943 --- /dev/null +++ b/packages/core/agent-tool-mode/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-tool-mode`. + * @module @deepseek-ai/dsh-agent-tool-mode/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode' + +/** Cordis companion plugin name. */ +export const name = 'tool-mode-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package makes exactly one scoped call into + * `ctx.tools` and owns no event or snapshot of its own; the relation it + * establishes — which presentation one agent's assembly uses — is the tool + * registry's to hold, and `dsh-tools` observes it there. + */ +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/core/agent-tool-mode/tests/agent-tool-mode.spec.ts b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts new file mode 100644 index 0000000000..ba9b9972ff --- /dev/null +++ b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts @@ -0,0 +1,129 @@ +/** + * The row an agent preset carries to pick its tool presentation. What it owes + * its caller: the choice reaches THIS agent and no other, it unwinds with the + * agent, and a code mode composed against a deployment with no code runtime + * stops at mount — where a preset's activation audit can name it — rather + * than at the first prompt assembly. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-mode' + +/** A runtime that never runs anything: presentation never dispatches. */ +class StubRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'stub' + + run(_request: CodeRunRequest): Promise<CodeRunResult> { + return Promise.resolve({ logs: [] }) + } +} + +/** A host plane with one tool, optionally carrying a code runtime. */ +async function host(options: { runtime?: boolean } = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(ToolRegistry, {}) + if (options.runtime !== false) await ctx.plugin(StubRuntime) + ctx.tools.register(defineTool({ + name: 'echo', + description: 'Echo tool.', + parameters: { value: { type: 'string', required: true } }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: args => Promise.resolve(args.value), + })) + return ctx +} + +/** Mount the row under one agent's scope, as a preset subtree does. */ +async function mount(ctx: Context, config: Config, id = 'agent') { + const agent = { id: SessionId(id) } as Agent + let inner!: Context + const fiber = ctx.plugin(Object.assign((host: Context) => { + inner = createScope(host, agent).ctx + }, { inject: ['tools', 'systemPrompt'] })) + await fiber.await() + const row = inner.plugin({ name, inject: [...inject], Config, apply }, config) + await row.await() + return { agent, fiber, row } +} + +describe('the tool-mode row', () => { + it('declares the services it uses without holding a code runtime hostage', () => { + // A `native` row must mount where no runtime is composed, so the wait is + // conditional inside apply rather than static metadata. + expect(inject).toEqual(['tools']) + }) + + it('gives its own agent Code Mode and leaves the rest native', async () => { + const ctx = await host() + const coded = await mount(ctx, { mode: 'code' }, 'coded') + const plain = await mount(ctx, { mode: 'native' }, 'plain') + + const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) + const plainAssembly = await ctx.systemPrompt.assemble({ scope: plain.agent }) + + expect(codedAssembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(codedAssembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('echo') + expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('presents both forms when asked for both', async () => { + const ctx = await host() + const { agent } = await mount(ctx, { mode: 'both' }) + + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME]) + }) + + it('restores the deployment default when the agent unloads', async () => { + const ctx = await host() + const { agent, row } = await mount(ctx, { mode: 'code' }) + + await row.dispose() + + // HMR safety: the preset subtree is torn down with its agent, and the + // presentation must go with it rather than outliving the composition. + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) + + it('waits for a code runtime the deployment does not compose', async () => { + const ctx = await host({ runtime: false }) + + const { agent, row } = await mount(ctx, { mode: 'code' }) + + // Pending, not applied: `dsh-agent-presets` rejects a mount holding a row + // that never reached a usable state, naming this id — so the preset fails + // where the operator can act, instead of at the first request. + expect(row.ctx.get('codeRuntime')).toBeUndefined() + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('applies once the runtime arrives', async () => { + const ctx = await host({ runtime: false }) + const { agent } = await mount(ctx, { mode: 'code' }) + + await ctx.plugin(StubRuntime) + + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + }) + + it('requires a mode rather than defaulting one', () => { + // An omitted value would mean the row was composed for nothing: a preset + // without this row already gets the deployment default. + expect(() => Config({} as never)).toThrow() + }) +}) diff --git a/packages/core/agent-tool-mode/tsconfig.json b/packages/core/agent-tool-mode/tsconfig.json new file mode 100644 index 0000000000..3b0445c30a --- /dev/null +++ b/packages/core/agent-tool-mode/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": "../../../vendor/schemastery" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 4df0056c1a..0f2fcabf3a 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -96,6 +96,7 @@ export interface CreateAgentOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } /** * Initial replay/fork history. A fork supplies a balanced completed-turn diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts index 4d36ca34fb..a49e2f5979 100644 --- a/packages/core/agent/src/model-selection.ts +++ b/packages/core/agent/src/model-selection.ts @@ -1,5 +1,5 @@ /** - * Agent-scoped model selection shared by interactive front doors. + * Agent-scoped model selection shared by runtime entry points. * @module @deepseek-ai/dsh-agent/model-selection */ @@ -33,7 +33,7 @@ export interface ModelSelectionRef { * the selected model's provider/default behavior. * * @param agentCtx - The selected Agent's scoped context. - * @param selection - Mutable selection owned by the calling front door. + * @param selection - Mutable selection owned by the calling entry point. * @returns Disposer for both scoped waterfall listeners. */ export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void { diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index b42a9e11c7..7df5df403c 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/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/core/scope/README.md -README.md: ecb442e39e40d5b97a07ccf8a71a190c4009ede8 -README.zh.md: 019e4c59dd788866e26b3b8a20b0023999f35ed2 +README.md: a8fbe97ae3b59f223bb52e44860439803fda420c +README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8 diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index ecb442e39e..a8fbe97ae3 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -2,20 +2,21 @@ English | [中文](README.zh.md) -Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents. +Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either. ## Public API -- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). +- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal. +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first. - `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. - `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. -- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics). +- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff its tag is the key or an ancestor of it; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics). - `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. - `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation. -- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer. +- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates and stays chain-blind (a scope's OWN contributions — restrictions, guards — must not silently pick up an ancestor's), `chainLayers()` returns existing overlays farthest-ancestor-first, `merge()` materializes insertion-ordered named shadows along the chain, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer. - `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo. - `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo. @@ -32,5 +33,5 @@ Handing out a scoped context hands out the minting plugin's service-resolution s ## Known Limitations and Deferred Work - **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context. -- **A context carries one nearest scope key** — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets. +- **A context carries one nearest scope key** — the hierarchy lives in the key-level parent relation, not in context tags; nested scope CONTEXTS still shadow to a single tag, and multi-membership policy sets remain unsupported. - **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder. diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index 019e4c59dd..af23823298 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -2,11 +2,12 @@ [English](README.md) | 中文 -带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。agent loop(智能体循环)为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包无需依赖 agent 即可使用。 +带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。 ## 公开 API -- `createScope(ctx: Context, key: ScopeKey): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。 +- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。 +- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。 - `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。 - `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。 - `Scope.dispose(): Promise<void>`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。 @@ -15,7 +16,7 @@ - `Scoped<T>`:编译期不透明载体 brand。按作用域筛选的事件要求它作为 `this` 类型,因此使用裸主体分发会产生编译错误。类型参数记录主体类型,但不公开其属性。 - `isScopeCarrier(value)`/`carrierKeyOf(value)`:运行时载体标记,开发不变式使用它们断言每次按作用域筛选的分发都携带载体,而且载体键与参数所指名的主体一致。 - `ScopeLayer`:一个注册表的完整全局贡献或精确作用域贡献的聚合约定;`isEmpty()` 控制带作用域层的回收。 -- `ScopedLayers<L>`:拥有一个立即创建的全局层和按需创建的精确作用域层。`peek()` 从不创建;`merge()` 物化按插入顺序排列的具名遮蔽项;`effect()` 从同一上下文推导可见性与所有权,同时返回原样 Cordis disposer。 +- `ScopedLayers<L>`:持有一个立即构造的全局层与惰性的精确作用域层。`peek()` 从不创建且刻意不看链(某作用域**自己**的贡献——限制、守卫——不得悄悄继承祖先的),`chainLayers()` 按最远祖先在前返回已存在的各层,`merge()` 沿链物化按插入序的具名遮蔽,`effect()` 从同一上下文推导可见性与所有权,并返回精确的 Cordis disposer。 - `NamedEntries<V>`:按插入顺序排列的具名存储,调用方拥有重复项诊断、查找,以及一个非空表世代内的实时迭代。表清空后,现有迭代器与后续插入项脱离;`insert()` 返回幂等的精确条目撤销函数。 - `AnonymousEntries<V>`:按插入顺序排列的匿名存储;唯一内部键使相同值仍作为独立注册存在。它使用相同的清空世代迭代器边界;`append()` 返回幂等的精确条目撤销函数。 @@ -32,5 +33,5 @@ ## 已知限制与暂缓事项 - **只有感知作用域的表层才会隔离状态**:注册表必须按 `scopeOf()` 归档,事件必须通过 `scopeTarget()` 分发;仅仅通过带作用域的上下文调用任意 Cordis 服务,并不会改变该服务仍为上下文全局这一事实。 -- **一个上下文只携带一个最近的作用域键**:嵌套作用域会遮蔽父作用域的标签,而不会形成层级策略集或多成员策略集。 +- **一个上下文只携带一个最近的作用域键**:层级关系存在于键级父关系中而非上下文标签里;嵌套作用域**上下文**仍遮蔽为单一标签,多成员策略集仍不受支持。 - **服务可达性来自作用域创建者**:交出 `Scope.ctx` 也会交出创建插件注入的服务表层,因此,若作用域创建者提供的服务范围较宽,持有者之后也无法将其收窄。 diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index fc5b1fa5fa..b5f58dbdf0 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -29,6 +29,78 @@ export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T } /** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */ const carrierKeys = new WeakMap<object, ScopeKey | undefined>() +/** + * The enclosing scope of each key. One relation powers both directions of + * scope nesting: registration views inherit DOWN the chain (a child scope + * sees its ancestors' layers — {@link ScopedLayers}), and event admission + * extends UP it (a listener tagged with an ancestor receives events dispatched + * to a descendant key — {@link scopeTarget}). + */ +const scopeParents = new WeakMap<ScopeKey, ScopeKey>() + +/** The privileged handle to move one scope key's parent link. */ +export interface ScopeParentBinding { + /** + * Re-link the bound key to a different parent, with the same cycle check as + * the bind. Valid only while nothing produced under the old parent is + * retained — the blank-session recompose contract, which the holder upholds + * because this relation cannot see what a session logged. + * @param parent - the new enclosing scope key. + */ + rebind(parent: ScopeKey): void +} + +/** Cycle-checked write shared by the bind and every rebind. */ +function linkScopeParent(key: ScopeKey, parent: ScopeKey): void { + for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) { + if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle') + } + scopeParents.set(key, parent) +} + +/** + * Bind `parent` as `key`'s enclosing scope, once. + * + * A key that already has a parent throws: there is no open re-link path, so a + * scope's ancestry cannot be moved by anyone but the original binder, who + * alone receives the {@link ScopeParentBinding}. A link that would close a + * cycle is rejected, because every chain consumer walks parents to the root. + * @param key - the child scope key. + * @param parent - its enclosing scope key. + * @returns the binding that alone may re-link this key. + */ +export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding { + if (scopeParents.has(key)) { + throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind') + } + linkScopeParent(key, parent) + return { + rebind(next: ScopeKey): void { + linkScopeParent(key, next) + }, + } +} + +/** + * Read one key's enclosing scope. + * @param key - the scope key to inspect. + * @returns its parent key, or `undefined` for a root scope. + */ +export function scopeParentOf(key: ScopeKey): ScopeKey | undefined { + return scopeParents.get(key) +} + +/** + * The chain from a key to its root ancestor. + * @param key - the starting key, or `undefined` for the empty chain. + * @returns keys nearest-first: `[key, parent, grandparent, …]`. + */ +export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] { + const chain: ScopeKey[] = [] + for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor) + return chain +} + /** A minted registration scope and its quiescent disposal boundaries. */ export interface Scope { /** Context through which scope-owned registrations are made. */ @@ -48,14 +120,22 @@ async function quiesceFiber(fiber: Fiber): Promise<void> { /** Shared no-op plugin used as the backing scope fiber. */ function scope(): void {} +/** Options accepted by {@link createScope}. */ +export interface CreateScopeOptions { + /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */ + parent?: ScopeKey +} + /** * Mint a scope under `ctx`. The scoped context inherits the minting plugin's * dependency surface and owns every registration made through it. * @param ctx - active context whose dependency surface the scope inherits. * @param key - opaque identity used for listener routing. + * @param options - optional scope-chain placement. * @returns the scoped context and exact/shared disposal boundaries. */ -export function createScope(ctx: Context, key: ScopeKey): Scope { +export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { + if (options?.parent !== undefined) bindScopeParent(key, options.parent) const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise<void> | undefined @@ -77,7 +157,12 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { /** * Build an opaque receiver that preserves the base filter, admits untagged - * listeners globally, and admits tagged listeners only for a matching key. + * listeners globally, and admits tagged listeners for a matching key or any + * of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing + * scope receives every descendant scope's events, which is what lets one + * standing composition observe each of the agents composed under it. A tag + * BELOW the dispatch key stays excluded — events flow up the chain, never + * down. * @param base - subject or service whose existing Cordis filter is preserved. * @param key - routed scope identity, or `undefined` for an unscoped subject. * @returns a carrier whose subject remains available only through event arguments. @@ -88,7 +173,11 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined [CordisContext.filter](ctx: Context): boolean { if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false const tag = scopeOf(ctx) - return tag === undefined || tag === key + if (tag === undefined) return true + for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) { + if (cursor === tag) return true + } + return false }, } carrierKeys.set(carrier, key) diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts index cdb34b50ce..a9e1468ccd 100644 --- a/packages/core/scope/src/store.ts +++ b/packages/core/scope/src/store.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' -import { scopeOf } from './index.ts' +import { scopeChainOf, scopeOf } from './index.ts' import type { ScopeKey } from './index.ts' /** One scope's aggregate contribution to a registry. */ @@ -170,7 +170,10 @@ export class ScopedLayers<L extends ScopeLayer> { } /** - * Read an existing exact-scope overlay. + * Read an existing exact-scope overlay. Deliberately chain-blind: callers + * addressing one scope's OWN contributions (its restrictions, its guards) + * must not silently pick up an ancestor's — use {@link chainLayers} where + * inheritance is the point. * @param scope - exact scope key; `undefined` denotes no overlay. * @returns the existing scoped layer, or `undefined` without creating one. */ @@ -180,8 +183,25 @@ export class ScopedLayers<L extends ScopeLayer> { } /** - * Materialize global named entries followed by exact-scope shadows. - * @param scope - exact viewing scope, or `undefined` for the global view. + * Existing overlays along the scope's parent chain ({@link scopeChainOf}), + * farthest ancestor first and the exact scope last, so a caller layering + * them in order gives the nearest scope the final word. + * @param scope - viewing scope, or `undefined` for no overlays. + * @returns the existing layers, nearest last; absent overlays are skipped. + */ + chainLayers(scope: ScopeKey | undefined): L[] { + const layers: L[] = [] + for (const key of scopeChainOf(scope).reverse()) { + const layer = this.scoped.get(key) + if (layer !== undefined) layers.push(layer) + } + return layers + } + + /** + * Materialize global named entries followed by scope-chain shadows, + * farthest ancestor first, so the nearest scope's entry wins a name. + * @param scope - viewing scope, or `undefined` for the global view. * @param pick - select the named table from a layer. * @returns an insertion-ordered effective map. */ @@ -190,9 +210,9 @@ export class ScopedLayers<L extends ScopeLayer> { pick: (layer: L) => NamedEntries<V>, ): Map<string, V> { const merged = new Map(pick(this.global).entries()) - const layer = this.peek(scope) - if (layer === undefined) return merged - for (const [name, value] of pick(layer).entries()) merged.set(name, value) + for (const layer of this.chainLayers(scope)) { + for (const [name, value] of pick(layer).entries()) merged.set(name, value) + } return merged } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 0b7bbef348..7007624d53 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { @@ -153,3 +153,70 @@ describe('scopeTarget', () => { expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>() }) }) + +describe('scope parent chain', () => { + it('links at mint, walks to the root, and rejects cycles', () => { + const ctx = new Context() + const preset = { kind: 'preset' } + const agent = { kind: 'agent' } + createScope(ctx, preset) + createScope(ctx, agent, { parent: preset }) + + expect(scopeParentOf(agent)).toBe(preset) + expect(scopeParentOf(preset)).toBeUndefined() + expect(scopeChainOf(agent)).toEqual([agent, preset]) + expect(scopeChainOf(undefined)).toEqual([]) + expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/) + expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/) + }) + + it('re-links only through the binding held by the original binder', () => { + const ctx = new Context() + const presetA = { id: 'a' } + const presetB = { id: 'b' } + const agent = { id: 'agent' } + createScope(ctx, presetA) + createScope(ctx, presetB) + const binding = bindScopeParent(agent, presetA) + createScope(ctx, agent) + + // A bound key cannot be re-bound from the outside; only the binding moves it. + expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/) + binding.rebind(presetB) + + expect(scopeChainOf(agent)).toEqual([agent, presetB]) + // The rebind keeps the cycle check: a parent may not adopt its ancestor. + const child = { id: 'child' } + const childBinding = bindScopeParent(child, agent) + void childBinding + expect(() => { binding.rebind(child) }).toThrow(/cycle/) + }) + + it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => { + const ctx = new Context() + const preset = { kind: 'preset' } + const agent = { kind: 'agent' } + const other = { kind: 'other-preset' } + const presetScope = createScope(ctx, preset) + const agentScope = createScope(ctx, agent, { parent: preset }) + const otherScope = createScope(ctx, other) + + const seen: string[] = [] + ctx.on('probe/event' as never, ((): void => { seen.push('untagged') }) as never) + presetScope.ctx.on('probe/event' as never, ((): void => { seen.push('preset') }) as never) + agentScope.ctx.on('probe/event' as never, ((): void => { seen.push('agent') }) as never) + otherScope.ctx.on('probe/event' as never, ((): void => { seen.push('other') }) as never) + + const emit = ctx as unknown as { emit: (carrier: object, type: string) => void } + // Dispatch at the AGENT key: its own tag and its ancestor's admit; a + // sibling root does not. + emit.emit(scopeTarget({}, agent), 'probe/event') + expect(seen.sort()).toEqual(['agent', 'preset', 'untagged']) + + // Dispatch at the PRESET key: the agent-tagged listener sits BELOW the + // dispatch key and stays excluded — events flow up the chain, not down. + seen.length = 0 + emit.emit(scopeTarget({}, preset), 'probe/event') + expect(seen.sort()).toEqual(['preset', 'untagged']) + }) +}) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 2e9bf49271..df32eaf80c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -149,6 +149,9 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { throw new Error('session header delegationDepth must be a non-negative safe integer') } + if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') { + throw new Error('session header agentPreset must be a string') + } return deepFreeze(record as unknown as SessionHeader) } @@ -898,6 +901,7 @@ export class SessionStore extends Service { ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, ...meta?.origin === undefined ? {} : { origin: meta.origin }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, + ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset }, } return Session.create(sessionId, seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 6074b51c02..35dd9d1dab 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -69,6 +69,13 @@ export interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } /** @@ -90,6 +97,7 @@ export interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 39f0530874..b0302b9d4d 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1319,6 +1319,7 @@ describe('SessionStore', () => { { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ }, + { meta: { agentPreset: 1 }, error: /agentPreset must be a string/ }, ] for (const [index, { meta, error }] of cases.entries()) { diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 16fc1394fa..6f1757ff28 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -110,6 +110,17 @@ export interface PromptAssembly { variables: Record<string, string | undefined> } +/** + * The deployment persona's section name and order. Exported because a + * composition can replace this slot — an agent preset shadows the + * deployment's persona with its own — and both sides naming the same section + * is what makes the replacement work rather than duplicate. + */ +export const PERSONA_SECTION = 'deployment:persona' + +/** Prompt order of the persona slot; the first section a model reads. */ +export const PERSONA_ORDER = 0 + /** Valid variable names: how they are written between the braces. */ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ @@ -337,8 +348,8 @@ export class SystemPrompt extends Service { }) } this.section({ - name: 'deployment:persona', - order: 0, + name: PERSONA_SECTION, + order: PERSONA_ORDER, // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', }) @@ -429,9 +440,11 @@ export class SystemPrompt extends Service { for (const [name, provider] of this.layers.global.variables.entries()) { variables[name] = provider(context) } - const scopedVariables = this.layers.peek(scope)?.variables - for (const [name, provider] of scopedVariables?.entries() ?? []) { - variables[name] = provider(context) + // Scope-chain variables, farthest first, so the nearest scope wins a name. + for (const layer of this.layers.chainLayers(scope)) { + for (const [name, provider] of layer.variables.entries()) { + variables[name] = provider(context) + } } // Scoped sections shadow globals before the stable order sort. const sectionByName = this.layers.merge(scope, layer => layer.sections) @@ -439,7 +452,7 @@ export class SystemPrompt extends Service { // Validate order against pre-restriction names while collecting visible schemas. const providers = [ ...this.layers.global.toolProviders.values(), - ...(this.layers.peek(scope)?.toolProviders.values() ?? []), + ...this.layers.chainLayers(scope).flatMap(layer => [...layer.toolProviders.values()]), ] const collected: ToolSchema[] = [] const knownNames = new Set<string>() diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 9d9ed71d8d..38a9c2ede3 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: b1de96293f8ec823ec52d6142a46de877f7fc5e6 -README.zh.md: fa42f7c02a09579bd7b1c995246696d8808889de +README.md: f3d1b4741c7fde64669794d079c36a18e633c0c1 +README.zh.md: d3054372095ef0cfdabc6cf80e0faa41a3b12d4c diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index b1de96293f..f3d1b4741c 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both. +Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both, and one agent shadows that default for itself with `presentAs`. ## Service: `ToolRegistry` (ctx key: `tools`) @@ -13,11 +13,12 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. +- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. - `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). @@ -190,6 +191,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. -- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own). +- **Code Mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (TypeScript or Python); scoped restrictions/shadows and `presentAs` choose each agent's visible bindings and their form, but within one agent no tool can be native-only while another is code-only. - **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index fa42f7c02a..d305437209 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者。 +工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者;单个 agent 可用 `presentAs` 为自己遮蔽该默认值。 ## 服务:`ToolRegistry`(ctx 键:`tools`) @@ -13,11 +13,12 @@ tools: mode: native # native (default) | code | both ``` -`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 +`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。 ### 公开 API - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 +- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。 - `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 @@ -190,6 +191,6 @@ The available tools: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。 - **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。 -- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责这次查表,以及注册表为何读取所加载的运行时而不自带 language 字段)。 +- **Code Mode 的 SDK 语言跟随已加载的那个运行时,且呈现方式按 agent 而非按工具**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK renderer(TypeScript 或 Python);作用域限制/遮蔽与 `presentAs` 会选择每个 agent 的可见绑定及其形态,但在同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。 - **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。 - **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 75c0751ad9..a44df0863f 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -623,11 +623,16 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` - * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. - * Code modes require a `ctx.codeRuntime` whose `language` has a registered - * SDK renderer (TypeScript or Python) and fail prompt assembly when it is - * absent or has no renderer. Under `code`, native names in `toolOrder` are invalid. + * Model presentation for agents that declare none of their own. `native` + * (default) sends every visible schema; `code` sends only `run_code` plus a + * generated SDK prompt; `both` sends both forms. Code modes require a + * `ctx.codeRuntime` whose `language` has a registered SDK renderer + * (TypeScript or Python) and fail prompt assembly when it is absent or has + * no renderer. Under `code`, native names in `toolOrder` are invalid. + * + * One agent overrides this for itself with {@link ToolRegistry.presentAs}, + * which is how an agent preset composes a Code Mode agent beside native + * ones in the same process. */ mode?: ToolPresentationMode /** @@ -682,6 +687,12 @@ class ToolLayer implements ScopeLayer { readonly tools: NamedEntries<ToolDefinition> readonly restrictions = new AnonymousEntries<CompiledToolRestriction>() readonly guards = new AnonymousEntries<ToolGuard>() + /** + * Presentation this scope's agent declared for itself, shadowing the + * deployment default. One cell rather than an entry table: two answers to + * "which form does the model see" is a contradiction, not a merge. + */ + mode: ToolPresentationMode | undefined constructor(scope: ScopeKey | undefined) { this.tools = new NamedEntries(name => new Error(scope === undefined @@ -692,6 +703,7 @@ class ToolLayer implements ScopeLayer { /** Whether every contribution table in this aggregate layer is empty. */ isEmpty(): boolean { return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() + && this.mode === undefined } /** Whether every compiled restriction in this layer admits a global tool name. */ @@ -772,60 +784,143 @@ export class ToolRegistry extends Service { scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }, ) - private readonly mode: ToolPresentationMode - /** Reserved presentation transport, kept outside the filterable registration layers. */ - private readonly codeTransport: ToolDefinition | undefined + /** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */ + private readonly defaultMode: ToolPresentationMode + private readonly maxParallelSubCalls: number + /** + * Reserved presentation transport, kept outside the filterable registration + * layers. Built on first need rather than at construction: which agents run + * a code mode is no longer known when the service is constructed, and the + * transport is stateless beyond its closures over `this`. + */ + private codeTransport: ToolDefinition | undefined constructor(ctx: Context, config: Config = {}) { super(ctx, 'tools') // The schema already defaulted an omitted mode; the ?? narrows the // optional-input type for direct (non-Loader) construction in tests. - this.mode = config.mode ?? 'native' - // `run_code` is presentation infrastructure, not an end capability. It - // therefore does not enter the global layer: per-agent restrictions must - // not remove it, and a scoped registration must not shadow it. The - // visibility resolver appends this reserved definition after resolving - // the filterable global/scoped capability layers. - this.codeTransport = this.mode === 'native' - ? undefined - : createRunCodeTool(this, { - requireRuntime: () => this.requireCodeRuntime(), - peekRuntime: () => this.ctx.get('codeRuntime'), - maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls), - shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), - }) + this.defaultMode = config.mode ?? 'native' + this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) - if (this.mode !== 'native') { - ctx.systemPrompt.section({ - name: 'tools:sdk', - order: SDK_SECTION_ORDER, - // Regenerate from the calling scope's visible tools in stable order, - // picking the renderer that matches the loaded runtime's language. - // `requireCodeRuntime` already validated the language is in the table, - // so the guard below is defense-in-depth against a caller that bypassed - // it (impossible under normal composition). - text: (context) => { - const runtime = this.requireCodeRuntime() - // Own-property read: a language like `toString`/`constructor` would - // otherwise resolve an inherited Object.prototype member as a renderer. - const render = SDK_RENDERERS[runtime.language] - /* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */ - if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) { - throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')})`) - } - return render(this.sdkSchemas(context.scope)) - }, - }) + if (this.defaultMode !== 'native') { + ctx.systemPrompt.section(this.sdkSection()) } } + /** + * The generated-SDK prompt section, registered globally by a code-mode + * deployment and per agent by {@link presentAs}. + * + * The body regenerates from the CALLING scope, and renders empty for an + * agent presenting natively — an agent that opted out under a code-mode + * deployment still sees the global registration, and an empty section is + * dropped from the rendered prompt. + * @returns the section registration. + */ + private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + return { + name: 'tools:sdk', + order: SDK_SECTION_ORDER, + // Regenerate from the calling scope's visible tools in stable order. + text: (context) => { + const mode = this.modeFor(context.scope) + if (mode === 'native') return '' + const runtime = this.requireCodeRuntime(mode) + // Own-property read: a language like `toString`/`constructor` would + // otherwise resolve an inherited Object.prototype member as a renderer. + const render = SDK_RENDERERS[runtime.language] + /* v8 ignore next -- requireCodeRuntime rejects an unknown language before this runs. */ + if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`) + return render(this.sdkSchemas(context.scope)) + }, + } + } + + /** + * The presentation one scope's agent sees: its own declaration, else the + * deployment default. + * @param scope - the calling agent, or undefined for the global view. + * @returns the resolved presentation mode. + */ + private modeFor(scope?: ScopeKey): ToolPresentationMode { + // Nearest scope wins along the chain: a preset's standing declaration + // covers every agent parented under it, and an agent's own (were one ever + // declared) would override its preset's. The mode decides what the model + // SEES, which is exactly the class of fact the chain inherits. + const layers = this.layers.chainLayers(scope) + for (let index = layers.length - 1; index >= 0; index -= 1) { + const mode = layers[index]?.mode + if (mode !== undefined) return mode + } + return this.defaultMode + } + + /** + * The reserved `run_code` transport, built on first need. + * + * It never enters the global layer: per-agent restrictions must not remove + * it, and a scoped registration must not shadow it. The visibility resolver + * appends it after resolving the filterable global/scoped capability layers, + * and only for scopes whose mode actually presents it. + * @returns the shared transport definition. + */ + private requireCodeTransport(): ToolDefinition { + this.codeTransport ??= createRunCodeTool(this, { + requireRuntime: () => this.requireCodeRuntime(this.defaultMode), + // The language-aware description/parameters getters read the runtime + // without demanding one, so a native-default process can still project + // the transport for an agent that chose code. + peekRuntime: () => this.ctx.get('codeRuntime'), + maxParallel: this.maxParallelSubCalls, + shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), + }) + return this.codeTransport + } + + /** + * Present this agent's tools in `mode` instead of the deployment default. + * + * Scoped only, and one declaration per agent: this is how an agent preset + * composes a Code Mode agent beside native ones in the same process, and a + * process-global override would be the `mode` config field instead. + * @param mode - the presentation this agent's model sees. + * @returns the exact disposer that restores the deployment default. + */ + presentAs(mode: ToolPresentationMode): () => void { + const ctx = this.ctx + if (scopeOf(ctx) === undefined) { + throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row') + } + const dispose = ctx.effect(function* (this: ToolRegistry) { + yield this.layers.effect( + ctx, + (layer) => { + if (layer.mode !== undefined) { + throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`) + } + layer.mode = mode + return () => { layer.mode = undefined } + }, + { label: 'tools.presentAs()' }, + ) + // The SDK section is per agent for the same reason the mode is. Under a + // deployment that already defaults to a code mode this shadows the + // global registration with an identical body, which costs nothing and + // keeps one rule instead of a case analysis. + if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection()) + }.bind(this), 'tools.presentAs()') + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity + return dispose + } + /** * Build one scope's wire schemas and names for prompt-order validation. * Restrictions do not make known tools invalid, but a mode collapse does. */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) - if (this.mode === 'native') { + const mode = this.modeFor(scope) + if (mode === 'native') { const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) return { schemas, knownNames: [...view.knownNames] } } @@ -834,9 +929,9 @@ export class ToolRegistry extends Service { // flavor-table guard would otherwise surface first. This keeps the // renderer-table rejection the canonical assembly-time error for a // language with no SDK renderer. - this.requireCodeRuntime() + this.requireCodeRuntime(mode) const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - if (this.mode === 'code') { + if (mode === 'code') { return { schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME], @@ -861,10 +956,10 @@ export class ToolRegistry extends Service { * point it is testable); rationale in the * [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md). */ - private requireCodeRuntime(): CodeRuntime { + private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime { const runtime = this.ctx.get('codeRuntime') if (!runtime) { - throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) + throw new Error(`dsh-tools: mode "${mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`) } if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) { const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ') @@ -893,7 +988,10 @@ export class ToolRegistry extends Service { && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`) } - if (this.codeTransport !== undefined && name === RUN_CODE_NAME) { + // Reserved unconditionally: any agent may select a code mode for itself, + // so a name free to take under the deployment default would become a + // collision the moment a preset mounted. + if (name === RUN_CODE_NAME) { throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) } return this.layers.effect( @@ -924,8 +1022,7 @@ export class ToolRegistry extends Service { ...allow !== undefined ? { allow: new Set(allow) } : {}, ...deny !== undefined ? { deny: new Set(deny) } : {}, } - if (this.codeTransport !== undefined - && [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) { + if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) { throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`) } const known = this.view(scope).restrictableNames @@ -958,11 +1055,16 @@ export class ToolRegistry extends Service { ) } - /** First monotonic denial from the global then matching scoped guard layers. */ + /** First monotonic denial from the global then the scope chain's guard layers, farthest first. */ private guardReason(exec: ToolExecution): string | undefined { const globalReason = this.layers.global.guardReason(exec) if (globalReason !== undefined) return globalReason - return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec) + if (exec.agent === undefined) return undefined + for (const layer of this.layers.chainLayers(exec.agent)) { + const reason = layer.guardReason(exec) + if (reason !== undefined) return reason + } + return undefined } /** @@ -974,26 +1076,34 @@ export class ToolRegistry extends Service { * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { - const layer = this.layers.peek(scope) + // Scope-chain layers, farthest ancestor first, the exact scope last. + const layers = this.layers.chainLayers(scope) const visible = new Map<string, ToolDefinition>() const knownNames = new Set<string>() const restrictableNames = new Set<string>() for (const [name, definition] of this.layers.global.tools.entries()) { knownNames.add(name) restrictableNames.add(name) - if (layer?.admits(name) ?? true) visible.set(name, definition) + // Restrictions intersect across the whole chain: any scope on it may + // mask a global-surface name for everything nested inside it. + if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // Scoped layer second: same-name entries REPLACE (shadow) the global ones, - // and scope-local registrations are never part of the global filter above. - for (const [name, definition] of layer?.tools.entries() ?? []) { - knownNames.add(name) - visible.set(name, definition) + // Chain layers second, nearest last: same-name entries REPLACE (shadow) + // the global and farther-scope ones, and scope-local registrations are + // never part of the global filter above. + for (const layer of layers) { + for (const [name, definition] of layer.tools.entries()) { + knownNames.add(name) + visible.set(name, definition) + } } // Presentation infrastructure is resolved last and outside capability // filtering. Registration rejects this reserved name, so the insertion is - // an invariant assertion as well as protection against future layer changes. - if (this.codeTransport !== undefined) { - visible.set(RUN_CODE_NAME, this.codeTransport) + // an invariant assertion as well as protection against future layer + // changes. Per scope: a native agent must not find `run_code` in its + // dispatch table because some other agent in the process presents it. + if (this.modeFor(scope) !== 'native') { + visible.set(RUN_CODE_NAME, this.requireCodeTransport()) } return { visible, knownNames, restrictableNames } } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 2f3454b03c..34758b840f 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1562,3 +1562,127 @@ describe('the run_code dispatch bridge', () => { expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) }) + +/** + * Presentation is per agent, because an agent preset composes it: one + * deployment runs a Code Mode agent beside native ones, and neither may see + * the other's catalog. The deployment `mode` is the default those agents + * shadow, not a process-wide fact. + */ +describe('per-agent presentation', () => { + it('gives one agent Code Mode while the deployment stays native', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'native' }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + + scope.ctx.tools.presentAs('code') + + const coded = await systemPrompt.assemble({ scope: agent }) + expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(coded.sections.find(section => section.name === 'tools:sdk')?.text) + .toContain('echo') + // The deployment default is untouched: an agent that declared nothing — + // and the global view behind it — still sees the native catalog. + const native = await systemPrompt.assemble() + expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + expect(native.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) + + it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { + const { bindScopeParent } = await import('@deepseek-ai/dsh-scope') + const { ctx, systemPrompt } = await setup({ mode: 'native' }) + registerEcho(ctx) + // The preset's standing scope declares once; the agent only PARENTS to it + // (the per-preset standing-mount shape — no per-agent declaration at all). + const standing = await mintAgentScope(ctx, 'preset:code-like') + standing.scope.ctx.tools.presentAs('code') + const joined = await mintAgentScope(ctx, 'joined-agent') + bindScopeParent(joined.agent, standing.agent) + const loner = await mintAgentScope(ctx, 'loner-agent') + + expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() + const coded = await systemPrompt.assemble({ scope: joined.agent }) + expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + // A sibling that never parented stays native, as does the global view. + expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() + const native = await systemPrompt.assemble({ scope: loner.agent }) + expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + }) + + it('keeps run_code out of a native agent\'s dispatch table', async () => { + const { ctx } = await setup({ mode: 'native' }) + registerEcho(ctx) + const coded = await mintAgentScope(ctx, 'coded') + const plain = await mintAgentScope(ctx, 'plain') + coded.scope.ctx.tools.presentAs('code') + + // Not merely hidden from the prompt: the transport one agent presents must + // not be dispatchable by another that never presented it. + expect(ctx.tools.get(RUN_CODE_NAME, coded.agent)).toBeDefined() + expect(ctx.tools.get(RUN_CODE_NAME, plain.agent)).toBeUndefined() + expect(ctx.tools.get(RUN_CODE_NAME)).toBeUndefined() + }) + + it('lets an agent opt out of a code-mode deployment', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'code' }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + + scope.ctx.tools.presentAs('native') + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + // The deployment's global section still reaches this scope; rendering it + // empty is what keeps the opted-out agent's prompt free of an SDK. + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toBe('') + }) + + it('restores the deployment default when the agent unloads', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'native' }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + const dispose = scope.ctx.tools.presentAs('code') + + dispose() + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + }) + + it('refuses a second declaration for the same agent', async () => { + const { ctx } = await setup({ mode: 'native' }) + const { scope } = await mintAgentScope(ctx) + scope.ctx.tools.presentAs('code') + + // Two answers to "which form does the model see" is a contradiction, and + // silently keeping either one would make the composition unreadable. + expect(() => scope.ctx.tools.presentAs('both')) + .toThrow('conflicts with "code" already declared') + }) + + it('refuses an unscoped declaration', async () => { + const { ctx } = await setup({ mode: 'native' }) + + expect(() => ctx.tools.presentAs('code')) + .toThrow('requires a scoped context') + }) + + it('reserves run_code even where no agent presents it', async () => { + const { ctx } = await setup({ mode: 'native' }) + + // The name must stay free under a native deployment too: an agent preset + // mounting later would otherwise collide with whatever took it. + expect(() => registerEcho(ctx, RUN_CODE_NAME)).toThrow('is reserved') + }) + + it('reports the missing runtime against the agent\'s own mode', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + scope.ctx.tools.presentAs('both') + + await expect(systemPrompt.assemble({ scope: agent })) + .rejects.toThrow('mode "both" requires a code runtime') + }) +}) diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index e77a9283b2..2270947eea 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/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/examples/README.md -README.md: 2d672dcc307bb280cf3803f29128eba4988a8da0 -README.zh.md: 24f64096dda0ccdac51afb90754ae25950750b89 +README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 +README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 diff --git a/packages/examples/README.md b/packages/examples/README.md index 2d672dcc30..d8369b1e26 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. +Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and an entry point by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. | Package | npm name | Role | |---|---|---| @@ -10,8 +10,8 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. -These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions. +These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 24f64096dd..acb402e925 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 +预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和运行入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 | 包 | npm 名称 | 角色 | |---|---|---| @@ -12,6 +12,6 @@ `agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 -这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包选择具体组合。 +这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 不要将此组与仓库根目录的 [`examples/`](../../examples/AGENTS.md) 混淆:该目录存放可运行的 `cordis.yml` **叶节点**;此组存放这些叶节点加载的 **组合包**。 diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index ff17cf5bb7..af16e2eacd 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/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/examples/acp-demo/README.md -README.md: 395ab230146568989c4e6d1361218efb72d857e7 -README.zh.md: 667fc1a794eba15c7754ad9887f8083d3643f3e6 +README.md: edc45c9857a631cef72eb41b1a98c390f112291e +README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 395ab23014..edc45c9857 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -55,4 +55,4 @@ Append-only per session; the app adds no request-prefix content itself. - **JSONL persistence is fixed** — a different backend requires another composition. - **Sibling plugins can corrupt stdout** — the app cannot prevent another entry from writing non-protocol bytes. -- **Fresh automation sessions only** — resume and human interaction belong to other front doors. +- **Fresh automation sessions only** — resume and human interaction belong to other entry points. diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index 667fc1a794..c2946aa3d1 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -55,4 +55,4 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 - **JSONL 持久化固定不变**:使用其他后端需要另一种组合。 - **同级插件可能破坏 stdout**:应用无法阻止另一个条目写入非协议字节。 -- **只支持新建自动化会话**:恢复和人工交互属于其他前端入口。 +- **只支持新建自动化会话**:恢复和人工交互属于其他运行入口。 diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 68a2909836..8a900dd3cd 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -71,7 +71,7 @@ export interface Config { goals?: agentCore.GoalConfig | false } -// Each front door owns a complete, directly readable config schema; extracting +// Each entry point owns a complete, directly readable config schema; extracting // the common fields would make two small app contracts depend on a new facade. /* jscpd:ignore-start */ export const Config: z<Config> = z.object({ @@ -114,7 +114,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> { const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) await spine yield spine.dispose - // Same rationale as the Config schema above: each front door forwards its own + // Same rationale as the Config schema above: each entry point forwards its own // persistence passthroughs rather than sharing a facade with stdio-demo. /* jscpd:ignore-start */ const persistence = ctx.plugin(SessionPersistenceJsonl, { diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index a698160634..44de45d88f 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: cfec2c46ada8ed97aef44fb1d4145ddecdeecab1 -README.zh.md: d482ea9ca7034874383472050a8c837bea5bfaad +README.md: cf0dc2ecd6e51eb872be75dfe6d80a5338605195 +README.zh.md: e5a8672d494e0c456aa820641e685d00be624445 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index cfec2c46ad..cf0dc2ecd6 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. +The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only an entry point and the swappable backends. Read this package for the whole plugin tree and its composition order. @@ -41,15 +41,15 @@ Read this package for the whole plugin tree and its composition order. ## What it deliberately leaves OUTSIDE the bundle -The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle: +The spine is everything COMMON to every entry point. The swappable and entry-point-coupled pieces stay out, picked by whatever loads the bundle: - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider. - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **front-door + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. +- **entry point + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. -This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. +This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the entry point. ## Config @@ -65,9 +65,9 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/ ## Why a code bundle, not a shared YAML include -A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. +A YAML include can deduplicate config but cannot own a bin or provide entry-point defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. -The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. +The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, entry points derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. ## Model Experience diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index d482ea9ca7..e5a8672d49 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。 +将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加入口和可替换后端,就能组合出可工作的 agent。 阅读此包可了解完整插件树及其组合顺序。 @@ -41,15 +41,15 @@ ## 有意留在组合包外的组件 -主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择: +主干包含每个入口都共有的全部组件。可替换组件和与入口耦合的组件留在外部,由加载组合包的一方选择: - **LLM(大语言模型)适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -- **前端入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 +- **入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 -这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 +这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有入口。 ## 配置 @@ -65,9 +65,9 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' ## 为何使用代码组合包,而非共享 YAML include -YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 +YAML include 可以去重配置,却无法拥有 bin 或提供入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 -重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 +重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 ## 模型体验 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 30c51fc941..7d9e99a908 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -165,7 +165,7 @@ export const Config = z.intersect([ ]) as unknown as z<Config> /** - * Copy the bundle-owned fields from an app config without leaking front-door settings. + * Copy the bundle-owned fields from an app config without leaking entry-point settings. * @param config - App config containing the shared spine fields. * @returns The fields accepted by this bundle, preserving optional absence. */ diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 8ffd971829..8de047a79a 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -236,7 +236,7 @@ describe('dsh-agent-spine-demo bundle', () => { } }) - it('loads and configures bounded request recovery for every bundled front door', async () => { + it('loads and configures bounded request recovery for every bundled entry point', async () => { const adapter = new TransientOnceAdapter() const ctx = await mount({ workspaceContext: false }) ctx.llm.registerAdapter(['mock'], adapter) @@ -704,9 +704,9 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) - it('picks shared spine config without leaking front-door fields', () => { + it('picks shared spine config without leaking entry-point fields', () => { const appConfig = { - model: 'front-door-only', + model: 'entrypoint-only', includeHarnessIdentity: false, persona: 'You are merged.', toolOrder: ['zulu'], diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index bc4a93d760..d919320643 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: e2eb6d4cf2b40e83efad1fa158edd72578658f56 +README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index e2eb6d4cf2..d7849e25fc 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -56,4 +56,4 @@ Independent of the model request path. Recording appends to the session log only - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. -- **Web only in the shipped front doors** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. +- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 30f7a65c51..cb86d86c92 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -143,7 +143,7 @@ export function apply(ctx: Context, config: Config): void { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] // Run the hook in the agent's session workspace (the `session/new` cwd on the session - // header), not the executor or front-door process's launch dir. + // header), not the executor or entry-point process's launch dir. const workdir = opts.agent?.session.header.cwd // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session // workspace (the same dir the hook runs in). diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e6ea7dd50d..cba01a89bf 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 44b8ee76b0da67ef1aaf24fb3d54e91d20fc064a -README.zh.md: 06b05aa64727c8bce08c7935040a1766f30b98ed +README.md: c29f30b85c5579f278ac9b40a0422347502eeb8f +README.zh.md: 92b866bafd71902c55bf0bad14c6b9e761421cf8 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 44b8ee76b0..c29f30b85c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,13 +46,17 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. + +`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. + +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 06b05aa647..92b866bafd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,7 +46,11 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 + +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 + +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 511fc25dba..06749f8f35 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -65,15 +65,17 @@ "zod": "^4.4.3" }, "peerDependencies": { - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "^0.0.1" + "@deepseek-ai/dsh-agent-presets": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@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:^" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e417ca12b2..1fbcfadd2d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import type { Context } from 'cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' @@ -26,6 +26,11 @@ import { WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). +import { + InvalidPresetIdError, PresetExistsError, PresetMountError, + PresetNotWritableError, resolveSessionPreset, + SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, @@ -58,6 +63,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and // `ctx.get('approval')` without a value dependency on the seam (optional composition). @@ -79,7 +85,7 @@ import { hasApiRemoteSubagentOwner, inspectApiRemoteSession, } from '@deepseek-ai/dsh-api-remotes' -import { openNativePath, openNativeTextFile } from './native-path-opener.ts' +import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -96,8 +102,15 @@ const COLD_SUMMARY_BATCH_SIZE = 16 /** Conversation message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Product settings intentionally exposed beside model-provider namespaces. */ -const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding']) +/** + * Product settings intentionally exposed beside model-provider namespaces. + * + * The agent-preset namespace carries one field — which preset a session with + * no explicit choice is composed from — and both browser surfaces that offer + * that choice write it through `settings.update`, so it has to cross the + * configuration boundary or the pickers silently fail to persist. + */ +const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE]) /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { @@ -206,6 +219,35 @@ function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> { return { rpcId: request.rpcId, result: { ok: false, error } } } +/** + * The RPC refusal a preset failure becomes, or undefined when the failure is + * about something else. + * + * Both the session-create path and the switch path can be handed the same two + * failures, and a client that has to branch on the code needs them worded the + * same from either. + * @param request - the request being answered. + * @param error - the thrown value. + * @returns the refusal, or undefined when the caller should keep handling. + */ +function presetFailure(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> | undefined { + if (error instanceof UnknownPresetError) { + return err(request, { + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + }) + } + if (error instanceof PresetMountError) { + return err(request, { + code: 'agent-preset-invalid', + message: error.message, + details: { agentPreset: error.presetId, reason: error.reason }, + }) + } + return undefined +} + /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */ class FrameQueue<F> { private buffer: F[] = [] @@ -266,15 +308,21 @@ function sessionBlank(session: Session): boolean { } /** Shared Session-header projection for list baselines and creation frames. */ -function sessionListFields(header: SessionHeader): { +function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): { parentSessionId?: SessionId origin?: 'subagent' cwd?: string + agentPreset?: string } { + // The preset comes from the log, not the header: a session that switched + // while blank ran its turns under the newer composition, and a picker + // showing the creation-time value would contradict what the model saw. + const agentPreset = resolveSessionPreset({ header, events }) return { ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }, ...header.origin === undefined ? {} : { origin: header.origin }, ...header.cwd === undefined ? {} : { cwd: header.cwd }, + ...agentPreset === undefined ? {} : { agentPreset }, } } @@ -287,7 +335,7 @@ function summarize(session: Session, running: boolean): SessionSummary { updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, running, blank: sessionBlank(session), - ...sessionListFields(session.header), + ...sessionListFields(session.header, session.events), } } @@ -321,12 +369,10 @@ async function summarizeCold( // a cold log to check for turns would defeat the index read, so a listed // cold session is served as not-blank (its log holds its conversation). blank: false, - ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession }, - ...meta.origin === undefined ? {} : { origin: meta.origin }, - /* v8 ignore next -- the empty arm needs a cwd-less meta, but list() - filters those out (legacy logs are not served); the conditional mirrors - summarize() shape. */ - ...meta.cwd === undefined ? {} : { cwd: meta.cwd }, + // Header-only: reading the log for a blank-window preset switch would + // defeat the same index read, and attaching the session replaces this row + // with `summarize()`, which resolves the switch from the events. + ...sessionListFields(meta), } } @@ -363,6 +409,14 @@ export interface ApiProxyDefaults { openPath?: (path: string, signal: AbortSignal) => Promise<void> /** Native text-editor handoff; injectable for settings-document tests. */ openTextFile?: (path: string, signal: AbortSignal) => Promise<void> + /** + * Whether handing a path to the native opener can work at all — the + * `hasDocument` capability the preset roster reports, and the switch + * between opening a preset directory and answering its path as text. + * Absent, an injected `openPath` counts as openable and everything else + * falls back to platform detection ({@link canOpenNativePath}). + */ + canOpenPath?: () => boolean } /** The tool/call payload fields the presenter path reads. */ @@ -437,11 +491,21 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: * the client's documented default (generic JSON card) covers every miss. */ -function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { +function viewFor( + ctx: Context, + event: SessionEvent, + argsFor: (callId: string) => unknown, + // Presenters live with the definitions, and definitions live in the scope + // chain: a preset registers its tools into its standing layer. A live agent + // is a scope whose chain passes through its preset; a cold read passes the + // preset's standing key directly — no agent, no resume. An undefined scope + // sees only the global layer, which is the pre-preset deployment shape. + scope?: ScopeKey, +): ToolEventView | undefined { try { if (event.type === 'tool/call') { const { name, arguments: raw } = event.data as ToolCallData - const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw)) + const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw)) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { @@ -450,7 +514,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => const callId = message.source.callId const call = argsFor(callId) as { name: string; args: unknown } | undefined if (call === undefined) return undefined - const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { + const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, { content: result.content, isError: result.isError === true, ...meta === undefined ? {} : { meta }, @@ -493,11 +557,12 @@ function historyPage( events: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number | undefined, + scope?: ScopeKey, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) + const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope) return { event, ...view === undefined ? {} : { view } } }), hasMore: page.hasMore, @@ -665,6 +730,57 @@ async function catalogChild( } } +/** + * The requested preset differs from the one this session already runs. + * + * A session's composition is fixed at creation: its history was produced under + * that preset's tools, so adopting the identity under a different one would + * replay tool calls the rebuilt agent cannot make. Naming a different preset + * is therefore a caller error rather than a switch. + */ +/** The roster is absent: this deployment composes no agent presets at all. */ +function noRoster(agentPreset: string): RpcError { + return { + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + } +} + +/** Map one authoring/roster failure onto its wire code. */ +function presetError(agentPreset: string, error: unknown): RpcError { + if (error instanceof UnknownPresetError) { + return { + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + } + } + if (error instanceof PresetNotWritableError) { + return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } } + } + if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { + return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } } + } + return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} } +} + +class AgentPresetConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedPreset: string, + readonly existingPreset: string | undefined, + ) { + super( + existingPreset === undefined + ? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; ` + + 'a deployment composing no roster records none on any session — ' + : `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; ` + + `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`, + ) + } +} + /** Requested identity already belongs to a session with another project cwd. */ class SessionCwdConflict extends Error { constructor( @@ -738,6 +854,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection } const selections = new WeakMap<Agent, WebModelSelectionRef>() + /** + * Serializes `agentPreset.select` per session. Two concurrent selects both + * pass the blank check, and the second `unmountPresetFor` then finds nothing + * to unmount because the first already removed the record — leaving two + * compositions registered into one agent layer. The client's `busy` flag is + * not enforcement: the wire is reachable directly. + */ + const presetSwitches = new Map<SessionId, Promise<unknown>>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ const sessionCreations = new Map<SessionId, Promise<Agent>>() /** Serializes path ownership and explicit title checks with Workspace mutations. */ @@ -794,6 +918,64 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro selectionFor(agent) } + /** + * Reject an attempt to run an existing session under a different preset. + * + * A caller that names no preset always adopts the session as it is, so the + * common paths — reconnecting, resuming, retrying a create — are unaffected. + * @param sessionId - the identity being adopted. + * @param requested - the preset the request named, if any. + * @param existing - the preset the session was created under, if any. + * @throws when both are present and differ. + */ + function assertPresetUnchanged( + sessionId: SessionId, + requested: string | undefined, + existing: string | undefined, + ): void { + if (requested === undefined || requested === existing) return + throw new AgentPresetConflict(sessionId, requested, existing) + } + + /** + * Resolve the preset an agent will be composed from, and the setup that + * installs it. + * + * The id is resolved BEFORE the session exists because the session boundary + * snapshots `meta` before asynchronous setup begins — a preset discovered + * during setup could never reach the header. Mounting still happens in + * setup, where a failure rolls the whole creation back rather than leaving a + * published session whose capabilities are half-installed. + * + * A deployment with no preset roster composes nothing and every session + * shares the host composition, which is the behavior before presets existed. + * @param presetId - the requested preset, or `undefined` for the default. + * @returns the id to record on the header (absent without a roster) and the setup callback. + * @throws when the roster supplies no such preset. + */ + async function composeAgent(presetId: string | undefined): Promise<{ + agentPreset?: string + setup: (agentCtx: Context) => Promise<void> + }> { + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return { + setup: (agentCtx: Context) => { + installSelection(agentCtx) + return Promise.resolve() + }, + } + } + const resolvedId = (await presets.resolve(presetId)).id + return { + agentPreset: resolvedId, + setup: async (agentCtx: Context) => { + installSelection(agentCtx) + await presets.mount(agentCtx, resolvedId) + }, + } + } + const hasSubagentOwner = ( session: Pick<Session, 'header'>, agent: Agent | undefined, @@ -802,7 +984,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro apiRemoteSubagentOwnershipError(sessionId) const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => inspectApiRemoteSession(ctx, sessionId) - const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installSelection }) + // Cold resume composes the preset the session recorded, for the same reason + // `session.create` does: its history was produced under that composition. + // Every generic entry point — prompt, models, commands — arrives here, so + // leaving it out meant a session opened after a restart ran on host tools + // and the deployment persona. Resolved from the LOG, not the header: a + // session that switched while blank ran its turns under the newer + // composition, and the header is written once at creation. Reading the + // header here would silently undo the switch on the next restart and + // restore that history under the old tool set. + const agentFor = createApiRemoteAgentResolver(ctx, { + agentOptions, + setup: async ({ meta, events }) => + (await composeAgent(resolveSessionPreset({ header: meta, events }))).setup, + }) /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { @@ -1023,23 +1218,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async function historyStateFor( sessionId: SessionId, includeProjections: boolean, - ): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> { const attached = ctx.sessions.get(sessionId) if (attached !== undefined) { const events = [...attached.events] const projections = includeProjections ? projectionsFor(ctx, attached) : undefined - return { events, ...projections === undefined ? {} : { projections } } + return { header: attached.header, events, ...projections === undefined ? {} : { projections } } } const inspected = await inspectServable(sessionId) const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined return { + header: inspected.meta, events: inspected.events, ...projections === undefined ? {} : { projections }, } } + /** + * The registry view scope a transcript's presenters resolve in. + * + * A live agent is that scope itself (its chain passes through its preset's + * standing layer). A cold session names its preset on the header, and the + * preset's STANDING key serves without resuming anything — ensuring the + * mount composes plugins but starts no agent, session, or turn. No roster, + * no recorded preset, or a preset the roster no longer supplies all fall + * back to the global layer: the transcript still serves, with the generic + * cards a viewless entry renders. + * @param sessionId - the transcript being read. + * @param header - that session's header (attached or inspected). + * @returns the scope to pass to presenter lookups, or undefined for global. + */ + async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> { + const live = ctx.get('agents')?.get(sessionId) + if (live !== undefined) return live + const presets = ctx.get('agentPresets') + if (presets === undefined) return undefined + try { + // An unrecorded preset (a log from before the roster existed) renders + // through the DEFAULT preset's standing layer: that is the composition + // an unnamed session composes today, and presenters are pure display, + // so the worst a mismatch produces is the generic card it had anyway. + return await presets.standingKeyFor(header.agentPreset) + } catch { + // Swallows only the unknown/unusable-preset rejection from the roster: + // a deleted or broken preset must degrade this read, never fail it. + return undefined + } + } + /** Resolve one requested identity to a live agent, creating or resuming it once. */ - async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> { + async function ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise<Agent> { let creation = sessionCreations.get(sessionId) if (creation === undefined) { creation = (async () => { @@ -1065,10 +1298,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (inspected.meta.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) } + // Resolved from the log, not the header: a session that switched + // while blank ran every turn under the newer composition. + const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) + assertPresetUnchanged(sessionId, presetId, storedPreset) + // The stored preset wins over anything the request names: a resumed + // session's history was produced under that composition, and + // rebuilding it differently would replay tool calls the model can no + // longer make. return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: agentOptions(), - setup: installSelection, + setup: (await composeAgent(storedPreset)).setup, })).agent } @@ -1077,11 +1318,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } + const composition = await composeAgent(presetId) return (await ctx.agents.create({ sessionId, agentOptions: agentOptions(), - meta: { cwd }, - setup: installSelection, + meta: { + cwd, + ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }, + }, + setup: composition.setup, })).agent })().catch((error: unknown) => { // Another Host entry path may have published the same identity while @@ -1103,6 +1348,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } const agent = await creation if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId) + // Beside the cwd check for the same reason, and after the await so it + // covers every path that yields a live agent — freshly created, adopted + // live, resumed from disk, or recovered by the concurrent-creation catch. + assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset) if (agent.session.header.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } @@ -1194,11 +1443,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return items } - /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */ - function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } { - const goals = ctx.get('goals') + /** + * Resolve the goal service THIS agent runs. + * + * The service is per session: an agent preset mounts it behind an `isolate` + * realm, which no host context resolves. Reading it from the root would + * answer "absent" for a session whose composition mounts it — so the lookup + * is keyed by the agent, and only a deployment composing it nowhere is + * genuinely absent. + */ + function goalServiceFor(agent: Agent): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } { + const presets = ctx.get('agentPresets') + const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals') if (goals === undefined) { - return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } } + return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } } } return goals } @@ -1214,10 +1472,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro request: RpcRequest<{ sessionId: SessionId }>, mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef, ): Promise<RpcResponse<{ ref: GoalRef }>> { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { const ref = mutation(goals, found.agent) return ok(request, { ref: { id: ref.id, revision: ref.revision } }) @@ -1314,6 +1572,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return openTarget(request, path, signal, open) } + /** Whether this deployment can hand a path to a native opener at all. */ + function canOpenPaths(): boolean { + if (defaults.canOpenPath !== undefined) return defaults.canOpenPath() + // An injected opener is by definition usable; otherwise ask the platform. + return defaults.openPath !== undefined || canOpenNativePath() + } + /** Missing-service report shared by the credentials domain. */ function credentialsAbsent(): RpcError { return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} } @@ -1572,9 +1837,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd + const requestedPreset = request.payload.agentPreset try { - await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined) + await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) } catch (error: unknown) { + if (error instanceof AgentPresetConflict) { + return err(request, { + code: 'agent-preset-conflict', + message: error.message, + details: { + sessionId: error.sessionId, + requestedPreset: error.requestedPreset, + ...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }, + }, + }) + } + const refused = presetFailure(request, error) + if (refused !== undefined) return refused if (error instanceof SessionCwdConflict) { return err(request, { code: 'session-conflict', @@ -1606,12 +1885,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } - return ok(request, { sessionId }) + // Echo the RESOLVED composition so a client can label the session it + // just created without waiting for the next list refresh — the create + // is the commit point that knows it (a caller that named none gets + // the default the header recorded). + const created = ctx.agents.get(sessionId) + const createdPreset = created?.session.header.agentPreset + return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) }, async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock } + let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock } try { state = await historyStateFor(sessionId, beforeSeq === undefined) } catch (error: unknown) { @@ -1624,7 +1909,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header)) return ok(request, { events: page.events, hasMore: page.hasMore, @@ -1766,6 +2051,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } const childId = `session-${randomUUID()}` as SessionId + // The child inherits the parent's composition for the same reason a + // resumed session keeps its own: the seeded history was produced under + // those tools, and composing anything else would strand the tool calls + // it already carries. Now that no model-facing row sits in the host + // plane, composing nothing would leave the child with no tools at all. + const forkComposition = await composeAgent(resolveSessionPreset(source)) try { await ctx.agents.create({ sessionId: childId, @@ -1774,9 +2065,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd }, parentSession: source.id, seedLength: cut, + ...forkComposition.agentPreset === undefined + ? {} + : { agentPreset: forkComposition.agentPreset }, }, agentOptions: agentOptions(), - setup: installSelection, + setup: forkComposition.setup, }) } catch (error: unknown) { return err(request, { @@ -2349,10 +2643,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async clear(request) { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { goals.clear(found.agent, request.payload.ref) return ok(request, { cleared: true as const }) @@ -2362,10 +2656,154 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + agentPresets: { + // A deployment with no roster answers with an empty list rather than an + // error: composing no presets is a valid deployment, and the browser + // simply offers no choice. + async list(request) { + const presets = ctx.get('agentPresets') + if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false }) + const defaultId = presets.defaultId + return ok(request, { + presets: (await presets.list()).map(preset => ({ + id: preset.id, + trust: preset.trust, + isDefault: preset.id === defaultId, + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + ...preset.broken === undefined ? {} : { broken: preset.broken }, + })), + authorable: presets.authorable, + hasDocument: canOpenPaths(), + }) + }, + + // Recomposing is limited to a blank session because a started + // conversation's history was produced under its preset's tools; the + // agent and the session survive, only the composition is swapped. + async select(request) { + const { sessionId, agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + }) + } + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const { agent } = found + const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => { + // Re-read inside the queue: an earlier switch may have run, and a + // conversation may have started, since this request arrived. + if (!sessionBlank(agent.session)) { + return err(request, { + code: 'agent-preset-locked', + message: `session "${sessionId}" has already started; its agent preset is fixed`, + details: { sessionId, agentPreset }, + }) + } + try { + const preset = await presets.recompose(agent.ctx, agentPreset) + // Recorded only after the swap committed: the log states what the + // agent runs, and a rejected mount leaves the previous composition. + agent.session.append('agent-preset/selected', { agentPreset: preset.id }) + return ok(request, { agentPreset: preset.id }) + } catch (error: unknown) { + const refused = presetFailure(request, error) + if (refused !== undefined) return refused + return err(request, { + code: 'internal', + message: `failed to select agent preset "${agentPreset}": ${String(error)}`, + details: {}, + }) + } + } + const queued = presetSwitches.get(sessionId) ?? Promise.resolve() + const turn = queued.then(swap) + presetSwitches.set(sessionId, turn.catch(() => undefined)) + try { + return await turn + } finally { + if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId) + } + }, + + // Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection): + // a composition names the plugins a session runs, so reading one is + // reconnaissance, and copy/remove/openDocument manage the roster and + // drive the host desktop. + async read(request) { + const { agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + const preset = await presets.resolve(agentPreset) + return ok(request, { + agentPreset: preset.id, + trust: preset.trust, + content: await presets.read(preset.id), + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, + }) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + + async copy(request) { + const { from, agentPreset, name } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + await presets.copy(from, agentPreset, name) + return ok(request, { agentPreset }) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + + async openDocument(request, signal) { + const { agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + const preset = await presets.resolve(agentPreset) + // Same line as copy/remove draw: the shipped install is not the + // user's to manage, and pointing an editor into it invites edits an + // upgrade will silently overwrite. + if (preset.trust !== 'user') { + throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + } + // The id resolved against the Host's own roots is what selects the + // directory — no browser payload carries a path in either direction + // unless the deployment has no opener to hand it to. + const directory = dirname(preset.path) + if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory }) + return await openPath(request, directory, signal) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + + async remove(request) { + const { agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) return err(request, noRoster(agentPreset)) + try { + await presets.remove(agentPreset) + return ok(request, {}) + } catch (error: unknown) { + return err(request, presetError(agentPreset, error)) + } + }, + }, + skills: { - // Skill lookup never touches the Agent registry: the session address - // resolves to a canonical cwd from the host-resident session header, so - // listing skills cannot create or resume an agent as a side effect. + // Skill lookup never creates or resumes an agent: the session address + // resolves to a canonical cwd from the host-resident session header, and + // the view scope is the live agent or the preset's standing key. async list(request) { const { sessionId } = request.payload const session = ctx.sessions.get(sessionId) @@ -2382,17 +2820,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) } const cwd = session.header.cwd - // Same stance as the commands domain: a missing service means the - // deployment omitted dsh-skill from its composition, not an empty - // catalog. ctx.get also keeps this handler independent of the gateway - // plugin's inject list (an undeclared `ctx.skills` property read - // fails the reflect proxy). - const skillRegistry = ctx.get('skills') + // The host registry is layered per scope and serves every session. A + // composition may still realm-mount its own registry instead; that + // instance is invisible to host contexts, so address it through the + // live agent (`agents.get` keeps the no-side-effect stance above). + const live = ctx.agents.get(sessionId) + const presets = ctx.get('agentPresets') + const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') + // Same stance as the commands domain: a missing service means no + // composition mounts dsh-skill, not an empty catalog. `ctx.get` also + // keeps this handler independent of the gateway plugin's inject list + // (an undeclared `ctx.skills` property read fails the reflect proxy). + const skillRegistry = scoped ?? ctx.get('skills') if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) + return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) } + // The scope presenters resolve in — the live agent, else the recorded + // preset's standing key, else the global layer — so a cold session's + // '/' popup lists the catalog its composition actually serves. + const scope = await presenterScopeFor(sessionId, session.header) try { - const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable) + const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, @@ -2622,8 +3070,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } else if (event.type === 'turn/end') { openCalls.delete(session.id) } - const view = viewFor(ctx, event, callId => - openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId)) + const view = viewFor( + ctx, event, + callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId), + ctx.agents.get(session.id), + ) queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) }), ctx.on('session/created', (session: Session) => { @@ -2657,7 +3108,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // has run no turn yet, so this is constantly true in practice. blank: sessionBlank(session), // Including cwd lets the client group the new session without refreshing the list. - ...sessionListFields(session.header), + ...sessionListFields(session.header, session.events), })) }), ctx.on('session/disposed', (session: Session) => { diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts new file mode 100644 index 0000000000..da3da6f9be --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -0,0 +1,88 @@ +/** + * agent-presets domain zod schemas (names derived from map keys: + * agentPresetListRequestSchema / agentPresetListValueSchema). + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import { sessionIdSchema } from './sessions.schema.ts' +import type { AgentPresetEntry } from './agent-presets.ts' + +/** AgentPresetEntry row of agentPreset.list. */ +export const agentPresetEntrySchema = z.object({ + id: z.string().min(1), + trust: z.union([z.literal('system'), z.literal('user')]), + isDefault: z.boolean(), + name: z.string().optional(), + description: z.string().optional(), + broken: z.string().min(1).optional(), +}) satisfies z.ZodType<Wire<AgentPresetEntry>> + +/** agentPreset.list request payload. */ +export const agentPresetListRequestSchema = z.object({ +}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.list'>>> + +/** agentPreset.list response value. */ +export const agentPresetListValueSchema = z.object({ + presets: z.array(agentPresetEntrySchema), + authorable: z.boolean(), + hasDocument: z.boolean(), +}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>> + +/** agentPreset.select request payload. */ +export const agentPresetSelectRequestSchema = z.object({ + sessionId: sessionIdSchema, + agentPreset: z.string().min(1), +}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.select'>>> + +/** agentPreset.select response value. */ +export const agentPresetSelectValueSchema = z.object({ + agentPreset: z.string(), +}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.select'>>> + +/** agentPreset.read request payload. */ +export const agentPresetReadRequestSchema = z.object({ + agentPreset: z.string().min(1), +}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.read'>>> + +/** agentPreset.read response value. */ +export const agentPresetReadValueSchema = z.object({ + agentPreset: z.string(), + trust: z.union([z.literal('system'), z.literal('user')]), + content: z.string(), + name: z.string().optional(), + description: z.string().optional(), +}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>> + +/** agentPreset.copy request payload. */ +export const agentPresetCopyRequestSchema = z.object({ + from: z.string().min(1), + agentPreset: z.string().min(1), + name: z.string().optional(), +}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.copy'>>> + +/** agentPreset.copy response value. */ +export const agentPresetCopyValueSchema = z.object({ + agentPreset: z.string(), +}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>> + +/** agentPreset.openDocument request payload. */ +export const agentPresetOpenDocumentRequestSchema = z.object({ + agentPreset: z.string().min(1), +}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.openDocument'>>> + +/** agentPreset.openDocument response value. */ +export const agentPresetOpenDocumentValueSchema = z.union([ + z.object({ opened: z.literal(true) }), + z.object({ opened: z.literal(false), path: z.string() }), +]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>> + +/** agentPreset.remove request payload. */ +export const agentPresetRemoveRequestSchema = z.object({ + agentPreset: z.string().min(1), +}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.remove'>>> + +/** agentPreset.remove response value. */ +export const agentPresetRemoveValueSchema = z.object({ +}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.remove'>>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts new file mode 100644 index 0000000000..76f5c05355 --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -0,0 +1,116 @@ +/** + * agent-presets domain contract: the roster a browser offers when starting a + * session, plus the authoring calls behind it. + * + * `list` is ordinary: it carries ids and trust, and every preset picker needs + * it. The authoring calls are privileged and loopback-pinned — a composition + * names the plugins a session runs, so reading one is reconnaissance, and + * although authoring is copy-only (no caller supplies composition text or a + * path), copying and deleting still rearrange what the deployment offers. + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RpcRequest, RpcResponse } from './rpc.ts' + +/** One preset the deployment can compose a session's agent from. */ +export interface AgentPresetEntry { + /** Stable identifier, also the display name until presets carry metadata. */ + readonly id: string + /** + * Whether the preset ships with the deployment or was authored locally. + * A `user` preset is exactly as privileged as the plugins it names, so a + * surface offering one should say so rather than present it as vetted. + */ + readonly trust: 'system' | 'user' + /** Whether a session that names no preset gets this one. */ + readonly isDefault: boolean + /** + * Display name the preset published, absent when it published none. A + * surface falls back to {@link id}; it is never a second identity, and it + * never decides trust — a locally authored preset cannot name itself into + * the shipped set. + */ + readonly name?: string + /** One sentence on what the preset is for, when it published one. */ + readonly description?: string + /** + * Why this preset cannot compose a session, absent when it can. A broken + * preset stays listed — its directory still occupies the id, so a surface + * must be able to show and delete it — but offering it for selection would + * only defer this reason to a failed session start. + */ + readonly broken?: string +} + +/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */ +export interface AgentPresetsApi { + /** + * Lists every preset the deployment currently supplies, in root-precedence + * order — the roots as configured, each root's own presets sorted by id, + * and the first root to supply an id wins. The order is not globally + * sorted: a user root's preset sits in that root's block, not among the + * shipped ids. + * An empty roster means the deployment composes no presets at all, and + * every session shares the host composition. `authorable` reports whether + * the deployment configures a root new presets can be written to, and + * `hasDocument` whether `openDocument` can hand a preset directory to a + * native opener — both deployment facts rather than per-preset ones, and + * neither exposes a Host path. + */ + list(request: RpcRequest<{}>): + Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>> + + /** + * Recompose one session's agent from a different preset. + * + * Allowed only while the session is blank — no turn has run. Once a + * conversation starts, its history was produced under that preset's tools, + * and swapping them would leave logged tool calls the new composition cannot + * make; the attempt answers `agent-preset-locked`. + */ + select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>): + Promise<RpcResponse<{ agentPreset: string }>> + + /** + * Read one preset's composition text, for the read-only viewer. + * + * Privileged: a composition names the plugins a session runs, so reading + * one is reconnaissance. + */ + read(request: RpcRequest<{ agentPreset: string }>): + Promise<RpcResponse<{ + agentPreset: string + trust: 'system' | 'user' + content: string + name?: string + description?: string + }>> + + /** + * Create a locally authored preset by copying an existing one whole. + * + * The only authoring write. No composition text and no path crosses the + * wire: `from` and `agentPreset` are ids the Host resolves against its own + * roots, so a copy is exactly as loadable as its source and grants nothing + * the roster did not already carry. The copy keeps the source's description + * (the file is the author's to edit afterwards) but not its name — `name` + * here or the id fallback is what distinguishes the rows. + */ + copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>): + Promise<RpcResponse<{ agentPreset: string }>> + + /** + * Hand one locally authored preset's DIRECTORY to the platform opener, for + * editing the files that are now the only composition editor. The request + * carries an id, never a path — the Host resolves it — so no browser + * payload can select an arbitrary filesystem target. Where the deployment + * has no native opener (`hasDocument: false` on `list`), the reply carries + * the resolved directory for the surface to show as text instead. Shipped + * presets are refused: their install is not the user's to manage. + */ + openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal): + Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>> + + /** Delete a locally authored preset. Shipped presets are refused. */ + remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>> +} diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 13ead7d08d..b432880810 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -73,6 +73,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), cwd: z.string().optional(), + agentPreset: z.string().optional(), }), z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }), z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index bf2f694eca..bbf895625f 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -116,6 +116,7 @@ export type HostFrame = parentSessionId?: SessionId origin?: 'subagent' cwd?: string + agentPreset?: string } | { type: 'host/session-removed'; sessionId: SessionId } | { type: 'host/session-status'; sessionId: SessionId; running: boolean } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 8e35c62514..83ca08c0a6 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' +import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' import type { EventsApi } from './events.ts' @@ -25,6 +26,7 @@ export interface ApiProxy { workspace: WorkspaceApi commands: CommandsApi skills: SkillsApi + agentPresets: AgentPresetsApi events: EventsApi goals: GoalsApi settings: SettingsApi @@ -48,6 +50,7 @@ export type { export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' +export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index deb963db07..f81f19ec19 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' +import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' @@ -51,6 +52,12 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'agentPreset.list': AgentPresetsApi['list'] + 'agentPreset.select': AgentPresetsApi['select'] + 'agentPreset.read': AgentPresetsApi['read'] + 'agentPreset.copy': AgentPresetsApi['copy'] + 'agentPreset.openDocument': AgentPresetsApi['openDocument'] + 'agentPreset.remove': AgentPresetsApi['remove'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 2733c6e940..5a758ee56f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -46,6 +46,11 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), + z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), + z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), + z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), + z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54bbb5a8cc..f799074c88 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,11 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-read-only': { agentPreset: string; reason: string } + 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } + 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } + 'agent-preset-not-found': { agentPreset: string; available: string[] } + 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } 'queue-item-not-found': { itemId: MessageId } 'steer-unavailable': { itemId: MessageId } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a1cc88dace..a33ab20f8f 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -55,6 +55,7 @@ export const sessionSummarySchema = z.object({ parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), cwd: z.string().optional(), + agentPreset: z.string().optional(), projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), }) as unknown as z.ZodType<Wire<SessionSummary>> @@ -100,6 +101,7 @@ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), cwd: z.string().optional(), sessionId: sessionIdSchema.optional(), + agentPreset: z.string().optional(), }).refine( payload => payload.workspaceId === undefined || payload.cwd === undefined, { message: 'session.create accepts workspaceId or cwd, not both' }, @@ -108,6 +110,7 @@ export const sessionCreateRequestSchema = z.object({ /** session.create response value. */ export const sessionCreateValueSchema = z.object({ sessionId: sessionIdSchema, + agentPreset: z.string().optional(), }) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>> /** session.rename request payload (raw title; host-side normalization decides acceptance). */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 0a4da455a2..f2a34e62c8 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -165,6 +165,13 @@ export interface SessionSummary { origin?: 'subagent' /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string + /** + * Agent preset this session's agent was composed from (header passthrough); + * absent when the deployment composes no presets. A surface offering a + * switch reads this to show what the session actually runs rather than what + * the deployment currently defaults to. + */ + agentPreset?: string /** * Projection baseline for this row, with zero log loads: attached sessions * read the registry's live watermark cut; cold sessions read the persisted @@ -208,9 +215,16 @@ export interface SessionsApi { * session, while a different cwd fails with `session-conflict`. Workspace * creation attaches the session after publication; an attach failure * returns `workspace-attach-failed` with the published session id. + * + * `agentPreset` names the composition the new session's agent is built + * from; omitted, the effective default applies — the user's stored choice + * where one exists, else the deployment's own. The resolved id is stored on + * the session header, so a later resume rebuilds the same agent. An unknown + * id fails with `agent-preset-not-found`, and a preset whose composition + * cannot be mounted fails with `agent-preset-invalid`. */ - create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>): - Promise<RpcResponse<{ sessionId: SessionId }>> + create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>): + Promise<RpcResponse<{ sessionId: SessionId; agentPreset?: string }>> /** * Reads a window of history events; page boundaries align to append-origin message diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0ce935809f..bbb8b3872a 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -40,6 +40,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' +import { + agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, + agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema, +} from '../api/agent-presets.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -121,6 +125,14 @@ export interface IApiClient { skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>> } + agentPresets: { + list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>> + select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>> + read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>> + copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>> + openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>> + remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>> + } events: { mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>> @@ -188,6 +200,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV 'command.list': commandListValueSchema, 'command.execute': commandExecuteValueSchema, 'skill.list': skillListValueSchema, + 'agentPreset.list': agentPresetListValueSchema, + 'agentPreset.select': agentPresetSelectValueSchema, + 'agentPreset.read': agentPresetReadValueSchema, + 'agentPreset.copy': agentPresetCopyValueSchema, + 'agentPreset.openDocument': agentPresetOpenDocumentValueSchema, + 'agentPreset.remove': agentPresetRemoveValueSchema, 'goal.create': goalCreateValueSchema, 'goal.edit': goalEditValueSchema, 'goal.pause': goalPauseValueSchema, @@ -447,6 +465,20 @@ export abstract class AbstractApiClient implements IApiClient { list: (payload, signal) => this.callUnary('skill.list', payload, signal), } + // Annotated like every sibling, and load-bearing rather than cosmetic: + // inferring this member inlines `AgentPresetEntry` into the emitted + // declaration by the specifier TS picks — the host `index.ts` — which drags + // the whole gateway, and with it the host `Context` merges, into every + // Client program that imports this carrier. + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal), + select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal), + read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal), + copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal), + openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal), + remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal), + } + readonly goals: IApiClient['goals'] = { create: (payload, signal) => this.callUnary('goal.create', payload, signal), edit: (payload, signal) => this.callUnary('goal.edit', payload, signal), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index bd3bc3827a..7474f371f1 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -42,6 +42,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' +import { + agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, + agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema, +} from '../api/agent-presets.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -111,6 +115,12 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, + 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, + 'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) }, + 'agentPreset.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) }, + 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, + 'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 8004f952e4..3e5157ca90 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,7 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. * * The gateway consumes `ctx.agentDefaultModel`, the transport-independent default - * shared with direct front doors. Switching models persists through that + * shared with direct entry points. Switching models persists through that * service; sessions that have already logged a selection remain unchanged. */ @@ -38,6 +38,14 @@ declare module 'cordis' { export interface Config { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } /** @@ -53,6 +61,7 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z<Config> = z.object({ workspaceRoot: z.string(), + nativeOpen: z.boolean(), }) readonly sessions: ApiProxy['sessions'] @@ -62,6 +71,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] + readonly agentPresets: ApiProxy['agentPresets'] readonly settings: ApiProxy['settings'] readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] @@ -76,6 +86,7 @@ export class ApiProxyService extends Service implements ApiProxy { saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd, workspaceRoot: resolve(config.workspaceRoot ?? cwd), + ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, }) this.sessions = api.sessions this.subagents = api.subagents @@ -84,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.commands = api.commands this.goals = api.goals this.skills = api.skills + this.agentPresets = api.agentPresets this.settings = api.settings this.credentials = api.credentials this.llm = api.llm diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index 6d8d3e0170..f8a065c8e2 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -152,6 +152,25 @@ async function openNativePathWithIntent( throw new Error(`native path opener is unsupported on ${platform}`) } +/** + * Whether {@link openNativePath} plausibly reaches a desktop on this host. + * + * macOS and Windows always carry a desktop opener; Linux does when it is WSL + * (the Windows desktop takes the path) or a display server is announced. + * A headless or containerised Linux host answers false, which is what lets a + * surface show a path as text instead of offering a button that would spawn + * `xdg-open` into nothing. + * @param internals - platform and environment seam for deterministic tests. + * @returns true when handing a path to the native opener can work at all. + */ +export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean { + const platform = internals.platform ?? process.platform + if (platform === 'darwin' || platform === 'win32') return true + if (platform !== 'linux') return false + const env = internals.env ?? process.env + return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY) +} + /** * Open a filesystem path with the operating system's default application, or * with the default browser when the path names a document a browser renders. diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts new file mode 100644 index 0000000000..24f08bae21 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -0,0 +1,652 @@ +/** + * A session's agent preset is fixed at creation. The gateway records the + * resolved id on the header and refuses to adopt the identity under a different + * one, because the session's history was produced under that preset's tools: + * rebuilding it differently would replay tool calls the new agent cannot make. + */ + +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { RpcId, type RpcRequest } from '../src/api/rpc.ts' +import { + InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' +import { GoalId } from '@deepseek-ai/dsh-goal' +import { createApiProxy } from '../src/api-proxy.ts' +import { describe, expect, it } from 'vitest' + +let nextRpc = 0 +function request<P>(payload: P): RpcRequest<P> { + return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload } +} + +/** Minimal live agent; the gateway only needs identity and its session. */ +function stubAgent(session: Session): Agent { + return { id: session.id, session, status: 'idle' } as unknown as Agent +} + +/** + * A roster whose `mount` is a no-op: this spec is about the gateway's identity + * rules, and the composition itself is covered by the real-composition test in + * `apps/cli`. Ids listed in `userIds` present as locally authored; the rest + * ship with the deployment. + */ +function roster(ids: readonly string[], userIds: readonly string[] = []): unknown { + const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system') + const presetOf = (id: string): object => + ({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` }) + return { + defaultId: ids[0], + list: () => Promise.resolve(ids.map(presetOf)), + resolve: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + return Promise.resolve(presetOf(wanted)) + }, + mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), + // What a real mount leaves behind: a service instance only the agent that + // mounted it can be used to address. The doubles are per agent so a test + // can tell "this session's" from "some session's". + serviceFor: (agent: { id: unknown }, name: string) => { + const perAgent = services.get(String(agent.id)) + return perAgent?.[name] + }, + authorable: true, + read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`), + copy: (from: string, id: string) => { + if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids)) + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id)) + if (ids.includes(id)) return Promise.reject(new PresetExistsError(id)) + return Promise.resolve() + }, + remove: (id: string) => { + if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) + return Promise.resolve() + }, + recompose: (_ctx: Context, id: string) => { + if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) + return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` }) + }, + // The standing scope key a cold transcript read resolves presenters in. + standingKeyFor: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + standingKeyRequests.push(wanted) + if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) { + return Promise.reject(new UnknownPresetError(wanted, ids)) + } + let key = standingKeys.get(wanted) + if (key === undefined) { + key = { agentPreset: wanted } + standingKeys.set(wanted, key) + } + return Promise.resolve(key) + }, + } +} + +/** Standing keys the roster double minted, and the ids readers asked for. */ +const standingKeys = new Map<string, object>() +const standingKeyRequests: string[] = [] +/** Preset ids whose standing mount the double reports as unusable. */ +const failingStandingKeys = new Set<string>() + +/** Per-agent service instances a mounted preset would own, keyed by session id. */ +const services = new Map<string, Record<string, unknown>>() + +async function harness( + presets?: readonly string[], + persistence?: unknown, + options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {}, +) { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never) + if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never) + + const factory: AgentFactory = { + async createAgent(_ownerCtx, options) { + const session = ctx.sessions.create( + options.sessionId, + options.meta === undefined ? {} : { meta: options.meta }, + ) + const agent = stubAgent(session) + // Setup runs before publication against a context that carries the + // agent, and the agent reaches back through `agent.ctx` — the pair the + // gateway's own `installTarget` relies on. + const agentCtx = ctx.extend({ agent }) + ;(agent as { ctx?: Context }).ctx = agentCtx + await options.setup?.(agentCtx) + const unregister = ctx.agents.register(agent) + return { agent, dispose: () => { unregister(); return Promise.resolve() } } + }, + async resume() { + throw new Error('test harness has no persisted sessions') + }, + } + ctx.agents.setFactory(factory) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), + cwd, + workspaceRoot: cwd, + ...options.defaults, + }) + return { api, ctx, cwd } +} + +describe('session.create with an agent preset', () => { + it('records the resolved preset on the session header', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + + const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' })) + + expect(created.result.ok).toBe(true) + expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal') + }) + + it('records the default when the caller names none', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + + await api.sessions.create(request({ sessionId: SessionId('s2') })) + + expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') + }) + + it('rejects an unknown preset and names the ones that exist', async () => { + const { api } = await harness(['standard']) + + const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('refuses to adopt a live session under a different preset', async () => { + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' })) + + const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-conflict') + expect(response.result.error.details).toEqual({ + sessionId: 's4', + requestedPreset: 'standard', + existingPreset: 'minimal', + }) + }) + + it('adopts a live session unchanged when the caller names no preset', async () => { + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' })) + + // Reconnecting and retrying a create must stay ordinary operations. + const response = await api.sessions.create(request({ sessionId: SessionId('s5') })) + + expect(response.result.ok).toBe(true) + }) + + it('leaves the header preset-less when no roster is composed', async () => { + const { api, ctx } = await harness() + + await api.sessions.create(request({ sessionId: SessionId('s6') })) + + expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() + }) + + it('says why a preset-less session cannot be adopted under one', async () => { + // Two callers reach this: a deployment that composes no roster, and a + // session created before one existed. Both record no preset, so naming + // any is a conflict rather than an adoption — the history was produced + // under a composition this roster cannot name. The message has to say + // that, because "already runs agent preset undefined" reads as a bug. + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('s7') })) + + const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-conflict') + expect(response.result.error.message).toContain('records no agent preset') + expect(response.result.error.details).toEqual({ + sessionId: 's7', + requestedPreset: 'standard', + existingPreset: undefined, + }) + }) +}) + +/** + * A capability a preset mounts is reachable from nowhere the host normally + * looks: an `isolate` realm is what makes it per session. The gateway serves + * requests that are ABOUT a session from OUTSIDE it, so it addresses the + * instance through the agent instead of reading a root-realm singleton. + */ +describe('a capability the session\'s preset mounts', () => { + it('serves the goal RPC from the session\'s own goal service', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' })) + const ref = { id: GoalId('goal-1'), revision: 1 } + const paused: unknown[] = [] + services.set('g1', { + goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } }, + }) + + const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref })) + + expect(response.result).toMatchObject({ ok: true, value: { ref } }) + // Reached the instance this session mounted, and was handed its own agent. + expect(paused).toEqual([['g1', ref]]) + services.delete('g1') + }) + + it('serves the skill catalog from the session\'s own registry', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' })) + services.set('k1', { + skills: { + list: () => Promise.resolve([{ + name: 'preset-owned', + description: 'ships inside the preset directory', + invocation: { modelInvocable: true, userInvocable: true }, + }]), + }, + }) + + const response = await api.skills.list(request({ sessionId: SessionId('k1') })) + + // A preset ships its own skill directory, so the catalog IS the + // session's; reading a host singleton would answer for the wrong one. + expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } }) + services.delete('k1') + }) + + it('says so when no composition mounts the capability at all', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' })) + + const response = await api.skills.list(request({ sessionId: SessionId('n1') })) + + // Absent means absent — not "this session has none", which is what a + // root-realm read used to report for every presetd session. + expect(response.result.ok).toBe(false) + const failure = response.result as { ok: false; error: { message: string } } + expect(failure.error.message).toContain('neither this session') + }) +}) + +describe('agentPreset.list', () => { + it('marks the default and carries each preset\'s trust', async () => { + const { api } = await harness(['standard', 'minimal']) + + const response = await api.agentPresets.list(request({})) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.presets).toEqual([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'minimal', trust: 'system', isDefault: false }, + ]) + expect(response.result.value.authorable).toBe(true) + }) + + it('answers with an empty roster when the deployment composes no presets', async () => { + const { api } = await harness() + + const response = await api.agentPresets.list(request({})) + + // Composing no presets is a valid deployment, not an error: every session + // then shares the host composition and the browser offers no choice. + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.presets).toEqual([]) + // Nothing to write to either, so a surface offering "new preset" knows to + // stay hidden rather than offering a button whose save always fails. + expect(response.result.value.authorable).toBe(false) + }) +}) + +describe('agentPreset.select', () => { + it('recomposes a blank session', async () => { + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.agentPreset).toBe('minimal') + }) + + it('records the switch in the log, and the list reads it back', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) + + await api.agentPresets.select( + request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' })) + + // The header is written once at creation, so the switch lives in the log — + // this is what a restart replays and what every projection resolves from. + // Asserting only the RPC's echo would miss a switch that never persisted. + const session = ctx.sessions.get(SessionId('sel-log')) + if (session === undefined) throw new Error('unreachable') + expect(session.header.agentPreset).toBe('standard') + expect(resolveSessionPreset(session)).toBe('core-web') + const listed = await api.sessions.list(request({})) + if (!listed.result.ok) throw new Error('unreachable') + expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) + .toBe('core-web') + }) + + it('serializes two concurrent selects on one session', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) + + // Both pass the blank check; unserialized, the second unmount finds no + // record because the first already removed it, and two compositions end up + // in one agent layer. The client's busy flag is not enforcement. + const [first, second] = await Promise.all([ + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })), + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })), + ]) + + expect(first.result.ok).toBe(true) + expect(second.result.ok).toBe(true) + const session = ctx.sessions.get(SessionId('sel-race')) + if (session === undefined) throw new Error('unreachable') + // One winner, and the log agrees with it: the last committed switch. + expect(resolveSessionPreset(session)).toBe('standard') + }) + + it('refuses once the conversation has started', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' })) + // One turn is enough: the history from here on was produced under + // `standard`'s tools, and a swap would strand those tool calls. + ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 }) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-locked') + }) + + it('reports an unknown preset without disturbing the session', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('sel-3') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports a deployment that composes no presets', async () => { + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('sel-4') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) +}) + +describe('authoring over the wire', () => { + it('reads a composition with its trust', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.read(request({ agentPreset: 'standard' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + // The shipped set is readable: it is the known-good composition a copy + // starts from, and trust is what tells a surface to say so. + expect(response.result.value.trust).toBe('system') + expect(response.result.value.content).toContain('- id: x') + }) + + it('copies a preset under a new id', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.copy( + request({ from: 'standard', agentPreset: 'mine', name: '我的模式' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.agentPreset).toBe('mine') + }) + + it('rejects a copy target that could escape the preset root', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-invalid') + }) + + it('rejects a copy target the roster already supplies', async () => { + const { api } = await harness(['standard', 'minimal']) + + const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-invalid') + expect(response.result.error.message).toMatch(/already exists/) + }) + + it('rejects a copy whose source is unknown', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports a deployment that composes no presets', async () => { + const { api } = await harness() + + const response = await api.agentPresets.read(request({ agentPreset: 'anything' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports an unknown id on delete rather than succeeding silently', async () => { + const { api } = await harness(['standard']) + + const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) +}) + +describe('opening a preset directory', () => { + it('hands the resolved directory to the native opener', async () => { + const opened: string[] = [] + const { api } = await harness(['standard', 'my-preset'], undefined, { + userIds: ['my-preset'], + defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, + }) + + const response = await api.agentPresets.openDocument( + request({ agentPreset: 'my-preset' }), new AbortController().signal) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value).toEqual({ opened: true }) + // The id selected the directory; the browser supplied no path. + expect(opened).toEqual(['/presets/my-preset']) + }) + + it('answers the path as text where the deployment has no opener', async () => { + const { api } = await harness(['standard', 'my-preset'], undefined, { + userIds: ['my-preset'], + defaults: { canOpenPath: () => false }, + }) + + const response = await api.agentPresets.openDocument( + request({ agentPreset: 'my-preset' }), new AbortController().signal) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' }) + }) + + it('refuses a preset that ships with the deployment', async () => { + const opened: string[] = [] + const { api } = await harness(['standard'], undefined, { + defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, + }) + + const response = await api.agentPresets.openDocument( + request({ agentPreset: 'standard' }), new AbortController().signal) + + // Pointing an editor into the install invites edits an upgrade will + // silently overwrite; the refusal mirrors copy/remove. + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-read-only') + expect(opened).toEqual([]) + }) + + it('reports the roster capability on list', async () => { + const openable = await harness(['standard'], undefined, { + defaults: { canOpenPath: () => true }, + }) + const headless = await harness(['standard'], undefined, { + defaults: { canOpenPath: () => false }, + }) + + const yes = await openable.api.agentPresets.list(request({})) + const no = await headless.api.agentPresets.list(request({})) + + expect(yes.result.ok && yes.result.value.hasDocument).toBe(true) + expect(no.result.ok && no.result.value.hasDocument).toBe(false) + }) + + it('counts an injected opener as openable', async () => { + const { api } = await harness(['standard'], undefined, { + defaults: { openPath: () => Promise.resolve() }, + }) + + const response = await api.agentPresets.list(request({})) + + expect(response.result.ok && response.result.value.hasDocument).toBe(true) + }) +}) + +describe('skills over the layered host registry', () => { + it('passes the live agent as the view scope to the host registry', async () => { + const { api, ctx } = await harness(['standard']) + const seen: unknown[] = [] + ctx.provide('skills', { + list: (options: { scope?: unknown }) => { + seen.push(options.scope) + return Promise.resolve([]) + }, + } as never) + await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' })) + + const response = await api.skills.list(request({ sessionId: SessionId('h1') })) + + expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) + expect(seen).toEqual([ctx.agents.get(SessionId('h1'))]) + }) + + it('resolves a cold session to its recorded preset standing key', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + const seen: unknown[] = [] + ctx.provide('skills', { + list: (options: { scope?: unknown }) => { + seen.push(options.scope) + return Promise.resolve([]) + }, + } as never) + ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } }) + + const response = await api.skills.list(request({ sessionId: SessionId('h2') })) + + expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) + expect(seen).toEqual([standingKeys.get('core-web')]) + }) + + it('serves the global view when the roster no longer supplies the recorded preset', async () => { + const { api, ctx } = await harness(['standard']) + const seen: unknown[] = [] + ctx.provide('skills', { + list: (options: { scope?: unknown }) => { + seen.push(options.scope) + return Promise.resolve([]) + }, + } as never) + ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } }) + + const response = await api.skills.list(request({ sessionId: SessionId('h3') })) + + expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) + expect(seen).toEqual([undefined]) + }) +}) + +describe('session.history presenter scope', () => { + it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' })) + // Cold: creation registered a live agent in this harness, so simulate the + // cold path by asking for a session only persistence knows... the harness + // has no persistence, so read the live one and assert no roster query. + standingKeyRequests.length = 0 + const live = await api.sessions.history(request({ sessionId: SessionId('p1') })) + expect(live.result.ok).toBe(true) + // A live agent IS the presenter scope; the roster is not consulted. + expect(standingKeyRequests).toEqual([]) + }) + + it('serves a COLD transcript whose standing mount is no longer usable', async () => { + // A genuinely cold session: persistence knows it, no live agent exists. + const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } + const { api } = await harness(['standard'], { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] }), + }) + // The preset broke after the session ran: the roster rejects the mount. + failingStandingKeys.add('standard') + try { + standingKeyRequests.length = 0 + const response = await api.sessions.history(request({ sessionId: SessionId('p3') })) + // Degraded, never failed: the roster WAS asked, and the transcript + // still serves — with the generic cards a viewless entry renders. + expect(standingKeyRequests).toEqual(['standard']) + expect(response.result.ok).toBe(true) + } finally { + failingStandingKeys.delete('standard') + } + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index c6cdc237b4..fee9c71b6f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -356,6 +356,21 @@ describe('settings domain', () => { expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) }) + it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { + const ctx = await harness() + ctx.settings.register(settingsNamespace('agent-presets'), z.object({ default: z.string() })) + const api = createApiProxy(ctx, DEFAULTS) + + expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'minimal' } }))) + + // Both browser surfaces that offer the choice — the General row and the + // management section — write the default through `settings.update`, so a + // namespace outside this boundary makes the picker move and then silently + // forget, which is worse than refusing the control. + expect(ctx.settings.describe().find(view => String(view.ns) === 'agent-presets')?.value) + .toEqual({ default: 'minimal' }) + }) + it('refuses even a model-provider namespace once its directory entry is gone', async () => { const ctx = await harness({ configurableProviders: false }) ctx.settings.register(NS, AdapterConfig) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ad95275e38..72654e868b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -23,6 +23,7 @@ function scriptedApi(overrides: { host?: Partial<ApiProxy['host']> commands?: Partial<ApiProxy['commands']> skills?: Partial<ApiProxy['skills']> + agentPresets?: Partial<ApiProxy['agentPresets']> events?: Partial<ApiProxy['events']> goals?: Partial<ApiProxy['goals']> settings?: Partial<ApiProxy['settings']> @@ -88,6 +89,15 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + agentPresets: { + list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), + select: r => ok(r, { agentPreset: r.payload.agentPreset }), + read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }), + copy: r => ok(r, { agentPreset: r.payload.agentPreset }), + openDocument: r => ok(r, { opened: true as const }), + remove: r => ok(r, {}), + ...overrides.agentPresets, + }, goals: { create: err, edit: err, @@ -222,6 +232,18 @@ describe('unary round trip', () => { expect(appended.result.ok).toBe(true) }) + it('routes the agent-preset roster and switch through the wire', async () => { + const c = client(scriptedApi()) + + const listed = await c.agentPresets.list({}) + expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } }) + + // The switch carries the session it is about: the host refuses one whose + // conversation has started, and it can only know which by id. + const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' }) + expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } }) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 83ada22644..9160a61e8e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -197,6 +197,32 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } }, }, + agentPresets: { + list(request: RpcRequest<{}>) { + return Promise.resolve({ + rpcId: request.rpcId, + result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } }, + }) + }, + select(request: RpcRequest<{ agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, + read(request: RpcRequest<{ agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, + copy(request: RpcRequest<{ from: string; agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, + openDocument(request: RpcRequest<{ agentPreset: string }>) { + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } }) + }, + remove(request: RpcRequest<{ agentPreset: string }>) { + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } }) + }, + }, skills: { async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } @@ -340,6 +366,28 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) + it('round-trips every agent-preset method, authoring included', async () => { + const c = client() + + // The whole domain crosses the carrier: the roster a picker reads, the + // per-session switch, and the authoring calls the settings page makes. + // Each has its own request schema, so a registration missing from either + // half fails here rather than in the browser. + expect((await c.agentPresets.list({})).result).toEqual({ + ok: true, value: { presets: [], authorable: false, hasDocument: false }, + }) + expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result) + .toEqual({ ok: true, value: { agentPreset: 'minimal' } }) + expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({ + ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' }, + }) + expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result) + .toEqual({ ok: true, value: { agentPreset: 'mine' } }) + expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result) + .toEqual({ ok: true, value: { opened: true } }) + expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} }) + }) + it('round-trips the native picker without the default unary timeout', async () => { const api = fakeApi() api.host.pickDirectory = async (request) => { diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index cf57bf103f..e1904cbcf1 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -16,7 +16,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' +import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' const signal = () => new AbortController().signal @@ -287,3 +287,35 @@ describe('browser-renderable documents', () => { ]) }) }) + +describe('canOpenNativePath', () => { + it('always answers yes where the desktop is part of the platform', () => { + expect(canOpenNativePath({ platform: 'darwin', env: {} })).toBe(true) + expect(canOpenNativePath({ platform: 'win32', env: {} })).toBe(true) + }) + + it('requires a display server or WSL interop on linux', () => { + const linux = { platform: 'linux' as const, osRelease: '6.8.0-generic' } + // Headless is the case the capability exists for: `xdg-open` would spawn + // into nothing, so a surface should show the path as text instead. + expect(canOpenNativePath({ ...linux, env: {} })).toBe(false) + expect(canOpenNativePath({ ...linux, env: { DISPLAY: ':0' } })).toBe(true) + expect(canOpenNativePath({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-0' } })).toBe(true) + expect(canOpenNativePath({ + platform: 'linux', osRelease: '5.15.153.1-microsoft-standard-WSL2', env: {}, + })).toBe(true) + }) + + it('answers no on a platform the opener does not support', () => { + expect(canOpenNativePath({ platform: 'freebsd', env: {} })).toBe(false) + }) + + it('samples the ambient environment when no override is supplied', () => { + const env = process.env + const marked = (value: string | undefined): boolean => value !== undefined && value !== '' + const expected = marked(env.WSL_DISTRO_NAME) || marked(env.WSL_INTEROP) + || marked(env.DISPLAY) || marked(env.WAYLAND_DISPLAY) + + expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected) + }) +}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 6d2ae5b23c..9824a637bf 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -32,6 +32,9 @@ import { commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { + agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, +} from '../src/api/agent-presets.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -508,3 +511,27 @@ describe('respond payload schemas', () => { expect(payload.sessionId).toBe('s') }) }) + +describe('agent-preset schemas', () => { + it('accepts a roster row and rejects an unknown trust', () => { + expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true })) + .toEqual({ id: 'standard', trust: 'system', isDefault: true }) + expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow() + expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow() + }) + + it('accepts an empty roster', () => { + // A deployment composing no presets still reports its authoring and + // native-open capabilities, so a surface knows what to offer. + expect(agentPresetListValueSchema.parse({ presets: [], authorable: false, hasDocument: false })) + .toEqual({ presets: [], authorable: false, hasDocument: false }) + }) + + it('answers the open-document union by its discriminant', () => { + expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true }) + expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' })) + .toEqual({ opened: false, path: '/presets/mine' }) + // A closed reply must carry the path the surface shows instead. + expect(() => agentPresetOpenDocumentValueSchema.parse({ opened: false })).toThrow() + }) +}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index eb22348935..624951059e 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../core/agent-default-model" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../core/session" }, diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 7d4217d3d4..2a9323474e 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: 6c8ba23b76e83665d4f8dcb5ecb41689347f6423 +README.md: c404cfa73024804bc9f166cfb84fa5f87f723459 README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 6c8ba23b76..c404cfa730 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -18,7 +18,7 @@ The review question declares the `plan-review` presentation intent, naming `Appr When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. -The Web client consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. +The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary. ## Session projection diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml new file mode 100644 index 0000000000..3690f87d6e --- /dev/null +++ b/packages/preset/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/preset/README.md +README.md: 5e7805eaa303b78c4d8a1a8972e1d031ee304fae +README.zh.md: 52f147f3c8128bce8377c20131d4312720f45bb6 diff --git a/packages/preset/README.md b/packages/preset/README.md new file mode 100644 index 0000000000..5e7805eaa3 --- /dev/null +++ b/packages/preset/README.md @@ -0,0 +1,16 @@ +# preset/ — per-session agent composition + +English | [中文](README.zh.md) + +An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it under an agent's scope context gives that session its own tools and prompt sections while every other live session keeps its own, so one process can run several differently composed agents at once. + +| Package | Role | ctx key | +|---|---|---| +| `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` | +| `persona/` | The agent persona as a composable row, so a preset can change identity and not only tools | — | + +The presets the deployment ships live in [`apps/cli/config/agent-presets/`](../../apps/cli/config/agent-presets) — one directory each, and that directory listing is the roster. Naming them here too would be a second list to keep in step, and the first one to fall behind. + +The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. + +Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md). diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md new file mode 100644 index 0000000000..52f147f3c8 --- /dev/null +++ b/packages/preset/README.zh.md @@ -0,0 +1,16 @@ +# preset/:按会话组装 agent + +[English](README.md) | 中文 + +**agent preset** 是一个目录,其中放置一份 `agent.cordis.yml`。把它挂载到某个 agent(智能体)的 scope 上下文之下,该会话就获得自己的工具与提示词段落,而其他在运行的会话各自保持不变,因此一个进程可以同时运行多个组装方式不同的 agent。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | +| `persona/` | 把 agent 人设做成可组装的行,使 preset 不止能改工具、也能改身份 | — | + +部署交付哪些 preset,看 [`apps/cli/config/agent-presets/`](../../apps/cli/config/agent-presets)——一个 preset 一个目录,那份目录列表就是清单。在这里再列一遍只会多出一份需要同步的名单,而且总是它先过时。 + +本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 + +设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。 diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml new file mode 100644 index 0000000000..cb80d89ad8 --- /dev/null +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md +README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6 +README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md new file mode 100644 index 0000000000..ed640cf053 --- /dev/null +++ b/packages/preset/agent-presets/README.md @@ -0,0 +1,128 @@ +# dsh-agent-presets + +English | [中文](README.zh.md) + +Per-preset agent composition. A **preset** is a directory holding one `agent.cordis.yml`; the roster mounts it ONCE per process under a standing scope, and each session that names it joins by having its agent scope key parented to the mount's (`dsh-scope`'s parent chain). The mount's tools, prompt sections, and projection units exist exactly once and cover every joined agent — its plugins key their state by Session/Agent, so sessions stay apart inside one shared instance — and a host reader with no agent at all (a cold transcript read) resolves the same standing registrations by preset id. + +The mechanism is two seams. Entry contexts chain to the context a subtree was plugged into, and both [`dsh-tools`](../../core/tools/README.md) and [`dsh-system-prompt`](../../core/system-prompt/README.md) file registrations into the calling context's scope layer — so the standing mount's contributions land in the PRESET's layer. What carries them to each session is `dsh-scope`'s parent chain: an agent's views resolve `agent → preset → global` (nearest shadowing farthest), and the mount's listeners are admitted for every agent parented under it while a sibling preset's stay deaf. + +## Service: `AgentPresets` (ctx key: `agentPresets`) + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call, so a preset authored while the process runs is visible immediately and a deleted one disappears from the next read. Discovery also owns preset **health**: a directory whose composition is missing or unloadable (unparsable YAML — checked with the loader's own dialect, `!!js` included — or not a list of named plugin rows) is listed with a `broken` reason rather than skipped, because a skipped directory would still occupy its id on disk while every surface shows nothing to delete. A directory whose name is not a usable preset id (`[a-z0-9][a-z0-9-]*`) is skipped outright: no copy could ever claim it. + +- `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none. +- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. +- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. +- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. +- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. +- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. +- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. +- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored. +- `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows. +- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick. + +`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), `path` (the absolute composition file), and — only when the preset cannot compose a session — `broken` (one human-readable reason, shown verbatim on roster surfaces). + +### Where to call `mount()` + +The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions. + +### Which preset a session runs + +The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. + +The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent. + +### Switching a blank agent + +`recompose()` unmounts the installed subtree and mounts the new one, because two compositions cannot coexist — both would register the same tool names into one layer. A failed mount restores the previous composition rather than leaving the agent with nothing, and an unknown id is rejected before anything is torn down. + +The restriction to a produced-nothing agent is a product rule, not a mechanical one: swapping tools mid-conversation would leave logged tool calls the new composition cannot make. The gateway enforces it at the wire ([`dsh-apiproxy`](../../host/apiproxy/README.md) answers `agent-preset-locked`), which is where session history is in hand. + +## Authoring + +Authoring is copy-only. A new preset is a whole-directory copy of an existing one — composition, metadata, skill directories, assets — landed under the first `user` root; the inputs are two ids the service resolves against its own roots plus an optional display name, so no caller ever supplies composition text and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files. `copy()` refuses three things before anything lands: + +- **An id that is not `[a-z0-9][a-z0-9-]*`.** The id becomes a directory name, so containment is a property of the id itself rather than of a path check after the fact — `../escape`, `a/b`, and an absolute path are all rejected as ids. +- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk refuses it too. Discovery lists such a directory as a broken preset, so the refusal's way out — delete it — is on the same page that reported it. +- **An unknown source.** The source may be any trust — copying a shipped preset is the primary case — but it must exist; a failed copy rolls its half-made directory back rather than leaving one discovery cannot see. + +The copied tree is re-tightened to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), symlinks are dereferenced so the copy is self-contained, and the root is created on first copy — a deployment configuring a user root that does not exist yet is the normal first-run state. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit in place, but its name and roster `order` are dropped — a copy presenting itself identically to its source, or sorted into the shipped set's declared order, would make the roster stop distinguishing them. `remove()` refuses a preset that ships with the deployment; the shipped set is the known-good compositions copies start from. + +### How a preset's rows resolve + +A row's **package name** resolves from the host composition, not from the preset directory. The Loader normally resolves an entry against its own tree's `baseUrl`, which for a preset is wherever the composition file sits; a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the harness, so every `@deepseek-ai/dsh-*` row would fail to import. The mount records the host base before plugging the subtree and sends bare specifiers there. + +A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it. + +### Display metadata + +A preset may publish display text in an optional `preset.yml` beside its composition: + +```yaml +name: 极简模式 +description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +``` + +It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load. + +Every read failure degrades to no metadata — absent, malformed, wrongly typed, or blank all mean the same thing, and a picker falls back to the id. Presentation is not capability: a preset with a broken name still mounts. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `default` | required | Preset id mounted when a caller names none | +| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) | + +An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. + +### The default preset is a user setting + +When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: + +```yaml +agent-presets: + default: minimal +``` + +The value is read per resolution rather than snapshotted, so a hot-reloaded document takes effect on the next session created and every running session stays on the preset it was composed from. Clearing the user field re-inherits the composition default. A default naming a preset no root supplies is stored without complaint and fails at the next `resolve()` — the roster is a live directory, so a name absent now may exist by the time a session asks for it. + +## What a mount rejects + +A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things. + +**An unscoped target.** Mounting into a context that carries no agent scope would register the preset's tools globally, for every agent in the process. + +**A row that never became usable.** The loader already rejects a row whose module failed to import or whose plugin threw; what remains is a row still waiting for a service the composition never supplies, which the audit names. + +**A row that published a service into the root realm.** Such a service is process-global, so the second preset publishing the same name collides with the first, and a host reader would resolve one preset's instance for every session. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local realms keep two presets' same-named services apart exactly as they once kept two sessions' apart — or the service belongs in the host composition instead. + +The package invariant re-checks that last rule on every service notification, because a row that publishes from a timer or an asynchronous continuation would escape the one-shot audit. + +## A preset file is an input, never a persistence target + +The Loader writes a tree back to its source file whenever it decides the config changed, and a row disposing its own fiber is enough to decide that: the entry is marked `disabled` and the tree is written. Inherited, that would burn one session's runtime state into a file every session shares — comments stripped by the YAML round trip, and a `writeFile` rejection inside a `setTimeout` for a read-only shipped preset. + +The mounted subtree therefore overrides `write()` as a no-op. Nothing in this package writes a composition; authoring one is a separate, explicit operation. + +## Trust + +Presets are compositions, so a preset is exactly as privileged as the plugins it names. A `user` preset — authored by a person or by an agent — carries the same trust as shell access; the `trust` field exists so consumers can present that difference, not to enforce it. + +## Model Experience + +Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it. + +#### KV Cache effect + +Prefix-stable for the life of an agent: a composition is installed once, before the agent is published and therefore before its first request, and is never re-read while the agent runs. Choosing a different preset for a new session establishes a different prefix for that session alone and cannot invalidate reuse for any session already running. + +## Known Limitations and Deferred Work + +- **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. +- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions). +- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start. +- **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back. +- **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file. +- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md new file mode 100644 index 0000000000..4e6fc0a4cf --- /dev/null +++ b/packages/preset/agent-presets/README.zh.md @@ -0,0 +1,128 @@ +# dsh-agent-presets + +[English](README.md) | 中文 + +按 preset 组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;roster 在整个进程内只把它挂载一次(常驻 scope),命名它的每个会话通过把自己 agent 的 scope key 认父到该挂载(`dsh-scope` 的父链)来加入。挂载的工具、提示词段落与投影单元只存在一份,覆盖所有已加入的 agent——其插件本就按 Session/Agent 分键存状态,会话在共享实例内互不串扰——而完全没有 agent 的宿主读取方(冷读记录)也能按 preset id 解析到同一份常驻注册。 + +其机制是两条 seam。entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册——因此常驻挂载的贡献落在 **preset 的分层**里。把它们送达每个会话的是 `dsh-scope` 的父链:agent 的视图按 `agent → preset → global` 解析(近者遮蔽远者),挂载的监听器对认父到它的每个 agent 放行,而兄弟 preset 的监听器保持失聪。 + +## 服务:`AgentPresets`(ctx 键:`agentPresets`) + +发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。发现过程同时负责 preset 的**健康**:组装文件缺失或不可加载(YAML 无法解析——用加载器自己的方言检查,含 `!!js`——或不是由具名插件行组成的列表)的目录会作为携带 `broken` 原因的行列出而不是被跳过,因为被跳过的目录仍在磁盘上占着它的 id,而各个界面却没有任何可删的东西。目录名不是可用 preset id(`[a-z0-9][a-z0-9-]*`)的目录才被直接跳过:复制永远不可能占用那种名字。 + +- `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。 +- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 +- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 +- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 +- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 +- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 +- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 +- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。 +- `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。 +- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。 + +`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)、`path`(组装文件的绝对路径),以及——仅当该 preset 无法组装会话时——`broken`(一条人类可读的原因,名单界面原样展示)。 + +### 应在何处调用 `mount()` + +agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。 + +### 会话实际运行的是哪个 preset + +创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 + +头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。 + +### 切换空白 agent + +`recompose()` 先卸载已装入的子树、再装入新的,因为两份组装无法共存——它们会把相同的工具名注册进同一个层。挂载失败会恢复先前的组装,而不是让 agent 一无所有;未知 id 则在任何东西被拆除之前就被拒绝。 + +"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。 + +## 创作 + +创作即复制。新 preset 是某个既有 preset 的整目录副本——组装、元数据、skill 目录、附带资产——落在首个 `user` 根目录之下;输入只有两个由服务对照自身根目录解析的 id 加一个可选显示名,因此调用方从不提供组装文本,一次复制不会授予 roster 尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。`copy()` 在任何内容落盘之前拒绝三种情况: + +- **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。 +- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字的目录同样拒绝。发现过程会把这样的目录列为损坏的 preset,所以这条拒绝的出路——删掉它——就在报告它的同一页面上。 +- **未知的来源。** 来源可以是任何信任级别——复制随附 preset 正是主要用途——但必须存在;复制失败会回滚做到一半的目录,而不是留下一个 discovery 看不见的目录。 + +复制出的目录树被收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),符号链接被解引用以保证副本自包含,且根目录在首次复制时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。复制出的 `preset.yml` 会被重写:保留来源的描述供作者就地编辑,但丢弃其名称与 roster `order`——副本若与来源呈现得一模一样、或按随附集合声明的顺序排序,roster 就不再能区分它们。`remove()` 拒绝随部署提供的 preset;随附集合正是副本的已知良好起点。 + +### preset 的各行如何解析 + +行的**包名**从宿主组装解析,而非从 preset 目录解析。Loader 通常按 entry 所属树的 `baseUrl` 解析,而对 preset 而言那就是组装文件所在之处;本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败。挂载在插入子树之前先记录宿主的基址,并把裸标识符送往那里。 + +**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。 + +### 展示用元信息 + +preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: + +```yaml +name: 极简模式 +description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +``` + +它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。 + +任何读取失败都退化为「没有元信息」——缺失、格式错误、类型不对、内容为空,含义相同,选择器回退到 id。展示不是能力:名字坏掉的 preset 依然能挂载。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `default` | 必填 | 调用方未指定时挂载的 preset id | +| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | + +根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 + +### 默认 preset 是一项用户设置 + +当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: + +```yaml +agent-presets: + default: minimal +``` + +该值在每次解析时读取而非快照,因此热重载的文档对**此后创建**的会话生效,而每个运行中的会话仍停留在它当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。若默认值指向没有任何根目录提供的 preset,写入时不会报错,而在下一次 `resolve()` 时失败——名单是一个活动目录,此刻不存在的名字,等到某个会话真正索取时可能已经存在。 + +## 挂载会拒绝什么 + +直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。 + +**目标上下文没有 scope。** 挂载到不带 agent scope 的上下文,会把该 preset 的工具注册成全局的,作用于进程内每一个 agent。 + +**某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。 + +**某一行把服务发布进了根 realm。** 这类服务是进程级全局的,因此第二个发布同名服务的 preset 会与第一个相撞,宿主读取方也会把某一个 preset 的实例当成所有会话的。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——entry 本地 realm 让两个 preset 的同名服务互不相干,正如它从前隔开两个会话——否则该服务应改放进宿主组装。 + +最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +## preset 文件是输入,不是持久化目标 + +只要 Loader 认为配置变了,它就会把树写回源文件——而一个行释放自己的 fiber 就足以让它这么认为:该 entry 被标记 `disabled`,随即触发写回。若继承该行为,一个会话的运行时状态就会被烧进所有会话共享的文件里:YAML 往返会抹掉注释,而对随附的只读 preset,`writeFile` 还会在 `setTimeout` 内抛出无人接管的 rejection。 + +因此被挂载的子树把 `write()` 覆写为空操作。本包不写任何组装;创作组装是另一件独立且显式的操作。 + +## 信任 + +preset 就是组装,因此一个 preset 的权限恰好等于它所引用的插件。`user` preset——无论由人还是由 agent 写出——与 shell 访问权限同级;`trust` 字段的存在是为了让消费方呈现这一差异,而不是用来强制隔离。 + +## Model Experience + +Indirectly, through the plugins a standing composition registers, which own every tool schema and prompt section the preset makes visible to the agents joined to it. + +#### KV Cache effect + +在一个 agent 的整个生命周期内保持前缀稳定:组装只装入一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间不再重新读取。为新会话选择不同的 preset,只会为该会话建立不同的前缀,无法让任何已在运行的会话失去缓存复用。 + +## Known Limitations and Deferred Work + +- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 +- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。 +- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。 +- **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。 +- **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。 +- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json new file mode 100644 index 0000000000..c012dc3eda --- /dev/null +++ b/packages/preset/agent-presets/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-agent-presets", + "description": "Per-session agent composition from preset cordis.yml files 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" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-atomic-write": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-settings-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts new file mode 100644 index 0000000000..5ac4e55874 --- /dev/null +++ b/packages/preset/agent-presets/src/authoring.ts @@ -0,0 +1,195 @@ +/** + * Copying, reading, and deleting locally authored presets. + * + * Authoring is confined to a `user` root: the shipped `.system` set is part of + * the deployment, and letting a browser rewrite it would turn "reset to a known + * preset" into something the same caller could have broken first. + * + * The only authoring write is a whole-directory copy of an existing preset. + * No caller supplies composition text: the inputs are ids the host resolves + * against its own roots plus an optional display name, so authoring grants no + * capability the copied preset did not already carry. + * @module @deepseek-ai/dsh-agent-presets/authoring + */ + +import { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { expandHomePath } from '@deepseek-ai/dsh-paths' +import { METADATA_FILE, renderPresetMetadata } from './metadata.ts' +import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' + +/** A preset id that cannot be used as a directory name under a root. */ +export class InvalidPresetIdError extends Error { + constructor( + /** The rejected id. */ + readonly presetId: string, + ) { + super( + `agent-presets: preset id ${JSON.stringify(presetId)} must match ${String(PRESET_ID)} — ` + + 'the id is a directory name, so anything else could escape the preset root', + ) + } +} + +/** A copy target that is already occupied — a copy never overwrites. */ +export class PresetExistsError extends Error { + constructor( + /** The id that is already taken. */ + readonly presetId: string, + ) { + super( + `agent-presets: preset "${presetId}" already exists — ` + + 'a copy never overwrites; delete the existing preset first or choose another id', + ) + } +} + +/** Authoring was attempted where the deployment allows none. */ +export class PresetNotWritableError extends Error { + constructor( + /** What the caller tried to change, for the diagnostic. */ + readonly presetId: string, + reason: string, + ) { + super(`agent-presets: preset "${presetId}" cannot be written: ${reason}`) + } +} + +/** + * The root locally authored presets are written to. + * @param roots - the configured roots in precedence order. + * @returns the absolute path of the first `user` root. + * @throws when the deployment configured no writable root. + */ +export function writableRoot(roots: readonly PresetRoot[]): string { + const root = roots.find(candidate => candidate.trust === 'user') + if (root === undefined) { + throw new PresetNotWritableError('', 'this deployment configures no user-writable preset root') + } + return resolve(expandHomePath(root.path)) +} + +/** + * Read one preset's composition text. + * @param preset - the resolved preset. + * @returns the file's contents. + */ +export async function readComposition(preset: AgentPreset): Promise<string> { + return await readFile(preset.path, 'utf8') +} + +/** Whether anything occupies the path (cp's own errorOnExist backstops races). */ +async function occupied(path: string): Promise<boolean> { + let present = true + try { + await stat(path) + } catch { + // Every stat failure means the same thing here: nothing usable occupies + // the path, so the copy may claim it. + present = false + } + return present +} + +/** + * Re-tighten a copied tree to owner-only. A shipped preset is world-readable + * in its install and `cp` preserves that; the copy carries the same weight as + * the settings document beside it, so group/other access is stripped. A + * file's owner-execute bit survives — a preset may ship runnable helpers. + */ +async function tightenModes(dir: string): Promise<void> { + await chmod(dir, 0o700) + for (const entry of await readdir(dir, { withFileTypes: true })) { + const target = join(dir, entry.name) + if (entry.isDirectory()) { + await tightenModes(target) + } else { + await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) + } + } +} + +/** + * Create a preset by copying an existing one's whole directory. + * + * The copy carries everything the source directory holds — composition, + * metadata, skill directories, assets — because a preset is its directory, + * not one file. Symlinks are dereferenced so the copy is self-contained + * rather than a set of links back into the install it was copied from. + * + * The copied metadata is then rewritten: the source's description is kept + * (the file is the author's to edit afterwards), but its name and roster + * `order` are not — a copy presenting itself identically to its source, or + * sorted into the shipped set's declared order, would make the roster stop + * distinguishing them. With no name given and no description to keep, the + * file is removed so the copy publishes nothing rather than a blank. + * @param roots - the configured roots; the first `user` one receives the copy. + * @param source - the resolved preset the copy starts from. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; omitted falls back to the id. + * @returns the absolute path of the new preset directory. + * @throws when the id is unusable or already occupied on disk, or the + * deployment configures no writable root. + */ +export async function copyComposition( + roots: readonly PresetRoot[], + source: AgentPreset, + id: string, + name?: string, +): Promise<string> { + if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id) + const dir = join(writableRoot(roots), id) + // The roster check upstream only sees discovered presets; a directory with + // no composition file still occupies the name and deserves a readable + // refusal rather than a filesystem error code. + if (await occupied(dir)) throw new PresetExistsError(id) + try { + await cp(dirname(source.path), dir, { + recursive: true, dereference: true, force: false, errorOnExist: true, + }) + await tightenModes(dir) + const rendered = renderPresetMetadata({ + ...name === undefined ? {} : { name }, + ...source.description === undefined ? {} : { description: source.description }, + }) + const metadataPath = join(dir, METADATA_FILE) + if (rendered === undefined) { + await rm(metadataPath, { force: true }) + } else { + await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 }) + } + } catch (error) { + // A half-copied directory would be invisible to discovery at best and a + // mountable-but-incomplete preset at worst; a failed copy leaves nothing. + await rm(dir, { recursive: true, force: true }) + throw error + } + return dir +} + +/** + * Delete a locally authored preset. + * + * A shipped preset is refused: it belongs to the deployment. A preset a live + * session mounted is NOT refused — the composition was read at creation and is + * never re-read, so that session keeps running exactly as it was. + * @param roots - the configured roots. + * @param preset - the resolved preset to remove. + * @throws when the preset ships with the deployment or lies outside the writable root. + */ +export async function deleteComposition( + roots: readonly PresetRoot[], + preset: AgentPreset, +): Promise<void> { + if (preset.trust !== 'user') { + throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + } + const dir = join(writableRoot(roots), preset.id) + // Belt and braces over the id pattern: the resolved directory must still be + // the one the writable root owns, whatever discovery reported. + if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) { + throw new PresetNotWritableError(preset.id, 'it does not live under the writable preset root') + } + await rm(dir, { recursive: true, force: true }) +} diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts new file mode 100644 index 0000000000..de8312893a --- /dev/null +++ b/packages/preset/agent-presets/src/discovery.ts @@ -0,0 +1,171 @@ +/** + * Filesystem discovery of agent presets. A preset is a directory holding + * {@link COMPOSITION_FILE}, optionally beside a {@link METADATA_FILE} carrying + * its display text; the directory name is the preset id. Discovery + * re-reads the roots on every call so a preset authored while the process is + * running is visible without a restart. + * + * Discovery also owns preset HEALTH: a directory whose composition is + * missing or unloadable is reported as a broken roster row rather than + * skipped. A skipped directory would still occupy its id on disk — the copy + * path refuses the name while no surface shows anything to delete — and a + * malformed composition would otherwise read as an ordinary preset until the + * first session fails to mount it. + * @module @deepseek-ai/dsh-agent-presets/discovery + */ + +import { readdir, readFile, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { load } from 'js-yaml' +import { entryListSchema } from '@cordisjs/plugin-include' +import { expandHomePath } from '@deepseek-ai/dsh-paths' +import { readPresetMetadata } from './metadata.ts' +import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' + +/** The composition file that makes a directory a preset. */ +export const COMPOSITION_FILE = 'agent.cordis.yml' + +/** + * Why `rows` cannot be an entry list, or undefined when it can. + * + * A shallow shape check, deliberately short of the loader's work: it does not + * resolve plugin names or apply configs. What it catches is the hand-edit + * that produces a file the loader cannot even begin with — and it must accept + * everything the loader accepts, which is why rows are only required to be + * maps carrying a plugin `name` (groups recurse into their own lists). + * @param rows - the parsed composition document. + * @param at - row-path prefix for nested diagnostics, empty at the top level. + * @returns one human-readable reason, or undefined when the shape holds. + */ +function entryListProblem(rows: unknown, at = ''): string | undefined { + if (!Array.isArray(rows)) { + return at === '' + ? 'the composition must be a top-level list of plugin rows' + : `group ${at} must hold a list of plugin rows` + } + for (const [index, row] of rows.entries()) { + const label = at === '' ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}` + if (typeof row !== 'object' || row === null || Array.isArray(row)) { + return `${label} is not a plugin row (expected a map with a "name")` + } + const { name, group, config } = row as { name?: unknown; group?: unknown; config?: unknown } + if (typeof name !== 'string' || name === '') { + return `${label} names no plugin (a "name" string is required)` + } + if (group === true) { + const nested = entryListProblem(config, label) + if (nested !== undefined) return nested + } + } + return undefined +} + +/** + * Why the composition at `path` cannot mount, or undefined when it looks + * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema}, + * the one carrying `!!js`), so health can never call a composition broken + * that the loader would accept. + * @param path - absolute path of the composition file. + * @returns one human-readable reason, or undefined when the file is loadable. + */ +async function compositionProblem(path: string): Promise<string | undefined> { + let content: string + try { + content = await readFile(path, 'utf8') + } catch { + // The caller statted this file moments ago; any read failure now — + // deleted in between, permissions — is the same answer as unparsable. + return `the composition file ${COMPOSITION_FILE} cannot be read` + } + let rows: unknown + try { + rows = load(content, { schema: entryListSchema }) + } catch (error) { + /* v8 ignore next -- js-yaml throws YAMLException (an Error) for every parse failure; the fallback keeps a hostile value readable */ + const full = error instanceof Error ? error.message : String(error) + // First line only: js-yaml appends a multi-line code-frame snippet, and + // the reason is displayed on a roster card, not in a terminal. + return `the composition is not valid YAML: ${full.replace(/\n[\s\S]*$/, '')}` + } + return entryListProblem(rows) +} + +/** + * Whether `path` names an existing regular file. + * @param path - absolute path to test. + * @returns true when the path resolves to a file. + */ +async function isFile(path: string): Promise<boolean> { + try { + return (await stat(path)).isFile() + } catch { + // Any stat failure — absent, unreadable, a dangling link — means this + // directory does not present a composition, which is not an error: the + // directory simply is not a preset. + return false + } +} + +/** + * Scan one root for preset directories. + * + * An absent root yields no presets rather than throwing: the user root does + * not exist until the first locally authored preset, and naming a default + * that no root supplies already fails loud at resolution. + * + * Every directory whose name is a usable preset id is a roster row — broken + * when its composition is missing or unloadable. A directory named outside + * {@link PRESET_ID} is skipped instead: no copy could ever claim that name, + * so it blocks nothing, and reporting `.DS_Store`-grade residue as broken + * presets would teach users to ignore the marker. + * @param root - the directory and the trust its presets inherit. + * @returns the root's presets ordered by id. + */ +export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> { + const dir = resolve(expandHomePath(root.path)) + let children + try { + children = await readdir(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error }) + } + const found: AgentPreset[] = [] + for (const child of children) { + if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue + const directory = join(dir, child.name) + const path = join(directory, COMPOSITION_FILE) + const broken = await isFile(path) + ? await compositionProblem(path) + : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file` + // Display text only, and never fatal: a preset with unreadable metadata + // still mounts, it just shows its id. + const metadata = await readPresetMetadata(directory) + found.push({ + id: child.name, trust: root.trust, path, ...metadata, + ...broken === undefined ? {} : { broken }, + }) + } + // Declared order first so the shipped set reads by capability; everything + // else falls back to the id, which keeps authored presets stable. + return found.sort((left, right) => { + const byOrder = (left.order ?? Number.POSITIVE_INFINITY) - (right.order ?? Number.POSITIVE_INFINITY) + return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder + }) +} + +/** + * Scan every root in precedence order. + * @param roots - roots in precedence order; an earlier root wins a duplicate id. + * @returns every discovered preset, first-root-wins per id. + */ +export async function discoverPresets(roots: readonly PresetRoot[]): Promise<AgentPreset[]> { + const byId = new Map<string, AgentPreset>() + for (const root of roots) { + for (const preset of await scanRoot(root)) { + if (byId.has(preset.id)) continue + byId.set(preset.id, preset) + } + } + return [...byId.values()] +} diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts new file mode 100644 index 0000000000..58f523c4e3 --- /dev/null +++ b/packages/preset/agent-presets/src/index.ts @@ -0,0 +1,457 @@ +/** + * Agent presets: each session composes its model-facing plugin set from one + * preset `cordis.yml`, mounted ONCE per preset under a standing scope and + * joined by every agent that names it. + * + * The standing mount is what makes a preset one composition rather than one + * per session: its plugin instances, tool registrations, prompt sections, and + * projection units exist exactly once, keyed per session inside the plugins + * themselves (they predate presets and were written for a shared world). An + * agent joins by having its scope key parented to the mount's + * ({@link bindScopeParent}), which makes the mount's registrations visible to + * that agent's views and the mount's listeners receive that agent's events — + * and a host reader with no agent at all (a cold transcript read) resolves + * the same standing registrations by preset id. + * + * This package owns the preset vocabulary, filesystem discovery, and the + * guarded standing mount. It does not decide when an agent is created — the + * agent factory's `setup(agentCtx)` hook is the one supported call site, + * because only there is the join installed while the agent is still + * unpublished, so a rejected composition rolls the whole creation back. + * @module @deepseek-ai/dsh-agent-presets + */ + +import { stat } from 'node:fs/promises' +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' +import { discoverPresets } from './discovery.ts' +import { copyComposition, deleteComposition, readComposition } from './authoring.ts' +import { mountPreset, serviceForAgent } from './mount.ts' +import { PresetExistsError } from './authoring.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts' + +/** Settings namespace carrying the user's chosen default preset. */ +export const SETTINGS_NAMESPACE = 'agent-presets' + +/** The user-writable slice of this plugin's config. */ +export interface AgentPresetSettings { + /** Preset mounted when a session names none. */ + default?: string +} + +/** Runtime schema for the user-writable slice. */ +export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({ + default: z.string(), +}) + +export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { + METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, +} from './metadata.ts' +export { + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, + type PresetMount, +} from './mount.ts' +export { + copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, + PresetNotWritableError, readComposition, writableRoot, +} from './authoring.ts' +export { resolveSessionPreset, type PresetBearingSession } from './session.ts' +export { PresetMountError, UnknownPresetError } from './types.ts' +export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' + +declare module 'cordis' { + interface Context { + agentPresets: AgentPresets + } +} + +/** + * Registry over the deployment's agent presets. + * + * Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every + * call so a preset authored while the process runs is visible immediately, + * and a preset deleted underneath a picker disappears from the next read. + */ +export class AgentPresets extends Service { + static inject = ['loader'] + + /** Runtime schema for the preset roster. */ + static Config = z.object({ + default: z.string().required(), + roots: z.array(z.object({ + path: z.string().required(), + trust: z.union(['system', 'user'] as const).default('user'), + })).default([]), + }) as z<Config> + + /** + * The user layer over `config.default`, present only while a settings + * provider is composed. Held rather than snapshotted so a hot-reloaded + * document takes effect without a restart. + */ + private settings: SettingsScope<AgentPresetSettings> | undefined + + /** + * The settings service behind {@link settings}, held for the one write this + * service makes: clearing a user default it has just deleted. + */ + private settingsService: SettingsService | undefined + + /** + * The service's own untraced context. Methods invoked through the traceable + * proxy see `this.ctx` rebound to the CALLER's context, which carries a + * shadow; a subtree minted from it resolves every service through that + * shadow's fiber instead of each entry's own inject store, so preset rows + * would fail on the very services they declare. Standing mounts must hang + * off the untraced original (the `tasks-local` selfCtx precedent). + */ + private readonly selfCtx: Context + + constructor(ctx: Context, public config: Config) { + super(ctx, 'agentPresets') + this.selfCtx = ctx + // Deliberately not `installSettingsSection`: that helper exists to re-judge + // what a consumer DERIVED from the source — memoized resolutions, + // registration-level facts — across attach, detach, and change. Nothing + // here is derived. `defaultId` reads through on every call, so both of its + // hooks would be no-ops and the source thunk would restate this field. + ctx.inject(['settings'], (settingsCtx) => { + this.settings = settingsCtx.settings.register( + settingsNamespace(SETTINGS_NAMESPACE), + AgentPresetSettingsSchema, + { base: { default: config.default } }, + ) + this.settingsService = settingsCtx.settings + settingsCtx.effect(() => () => { + this.settings = undefined + this.settingsService = undefined + }, 'agentPresets.settings()') + }) + } + + /** + * The preset id mounted when a caller names none. + * + * Read per call rather than cached: the settings document is hot-reloaded, so + * changing the default takes effect on the next session created and leaves + * every running session on the preset it was composed from. + */ + get defaultId(): string { + return this.settings?.get().default ?? this.config.default + } + + /** + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. + */ + async list(): Promise<AgentPreset[]> { + return await discoverPresets(this.config.roots) + } + + /** + * Resolve one preset by id. + * + * A broken preset resolves — deleting one, reading one, and reporting one + * all need the row — and the mounting paths refuse it AFTER resolution + * through {@link resolveMountable}. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. + * @throws when no configured root supplies that id. + */ + async resolve(id?: string): Promise<AgentPreset> { + const wanted = id ?? this.defaultId + const presets = await this.list() + const found = presets.find(preset => preset.id === wanted) + if (found === undefined) { + throw new UnknownPresetError(wanted, presets.map(preset => preset.id)) + } + return found + } + + /** + * Resolve one preset that is about to compose an agent, refusing a broken + * one with its discovery-reported reason. Failing here rather than inside + * the loader keeps the answer the same for every unloadable shape — ghost + * directory, unparsable YAML, rowless list — and spends no mount attempt + * on a composition discovery already read as unusable. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved, mountable preset. + * @throws when the preset is unknown or discovery reports it broken. + */ + private async resolveMountable(id?: string): Promise<AgentPreset> { + const preset = await this.resolve(id) + if (preset.broken !== undefined) { + throw new PresetMountError(preset.id, preset.broken) + } + return preset + } + + /** + * Standing mounts by preset id, single-flight so two agents racing the + * first use of one preset share one composition. A settled failure is + * removed so a later session retries a preset whose file has been fixed; a + * settled success serves until the composition FILE visibly changes — each + * generation records its file stamp, and a stale stamp starts the next + * generation for sessions created afterwards. Sessions already joined keep + * the generation they run on; a superseded one is never disposed while the + * process lives (reclaimed only by whole-tree teardown), so editing files + * is bounded by how often compositions change, not by session count. + */ + private readonly standing = new Map<string, Promise<StandingMount>>() + + /** + * Parent bindings of the agents this roster composed, keyed by the agent's + * scope key. The binding is dsh-scope's only re-link capability; holding it + * here makes this service the sole authority that can move an agent between + * standing compositions. WeakMap: entries die with their agents. + */ + private readonly bindings = new WeakMap<ScopeKey, ScopeParentBinding>() + + /** + * Compose one agent from a preset: ensure the preset's standing mount, then + * parent the agent's scope key to it so the mount's registrations and + * listeners cover this agent. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken preset never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was composed, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ + async mount(agentCtx: Context, id?: string): Promise<AgentPreset> { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset') + } + const preset = await this.resolveMountable(id) + const standing = await this.ensureStanding(preset) + // The one bind of this agent's ancestry. The binding is the only re-link + // authority, held privately so nothing outside this roster can move a + // composed agent to another preset; a later recompose layer re-links + // through it under the caller-owned blank-session contract. + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + return preset + } + + /** Whether this deployment configures a root locally authored presets go to. */ + get authorable(): boolean { + return this.config.roots.some(root => root.trust === 'user') + } + + /** + * Read one preset's composition text. + * @param id - the preset id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ + async read(id: string): Promise<string> { + return await readComposition(await this.resolve(id)) + } + + /** + * Create a locally authored preset by copying an existing one whole. + * + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. + */ + async copy(from: string, id: string, name?: string): Promise<void> { + const source = await this.resolve(from) + // The roster check refuses ids any root supplies — shipped ones included, + // since a user directory named like a shipped preset is shadowed by it. + // The disk check inside copyComposition only sees the writable root. + if ((await this.list()).some(preset => preset.id === id)) { + throw new PresetExistsError(id) + } + await copyComposition(this.config.roots, source, id, name) + // A settled mount under this id can only be stale (its preset was deleted + // from disk outside `remove`); the new preset must not inherit it. Every + // session already joined keeps the generation it runs on regardless. + this.standing.delete(id) + } + + /** + * Delete a locally authored preset. + * @param id - the preset id. + * @throws when the preset is unknown or ships with the deployment. + */ + async remove(id: string): Promise<void> { + await deleteComposition(this.config.roots, await this.resolve(id)) + // Sessions on the deleted preset keep their standing mount; only new + // sessions see the roster without it. + this.standing.delete(id) + // Storing a default that does not exist YET is deliberate — the roster is a + // live directory, so a name absent now may exist by the time a session asks + // for it, and `resolve` reports it then. A default this call just deleted is + // not that case: nothing will ever supply it again, and left in place every + // session created without an explicit pick would fail to start. Clearing it + // exposes the deployment's own default underneath, which is the layering. + if (this.settings?.get().default !== id) return + await this.settingsService?.mutate( + settingsNamespace(SETTINGS_NAMESPACE), + [{ op: 'unset', path: ['default'] }], + ) + } + + /** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ + serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined { + return serviceForAgent(this.ctx, agent, name) + } + + /** + * Re-link one agent to a different preset's standing composition. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot + * make. The CALLER owns that check — this method does not read session + * history. + * + * The swap is a parent re-link, not an unmount: standing mounts are shared + * and permanent, so the old composition stays for its other agents and the + * new one is ensured BEFORE the link moves. An unknown or unusable preset + * therefore throws with the agent exactly as it was — there is no torn-down + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable. + */ + async recompose(agentCtx: Context, id: string): Promise<AgentPreset> { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to recompose an unscoped context') + } + const preset = await this.resolveMountable(id) + const standing = await this.ensureStanding(preset) + const binding = this.bindings.get(agentKey) + if (binding === undefined) { + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + } else { + binding.rebind(standing.key) + } + return preset + } + + /** + * The standing scope key of one preset, for a host reader with no agent. + * + * A cold transcript read resolves tool presenters against the composition + * the session recorded, and the standing mount makes that possible without + * resuming anything: ensuring the mount composes plugins but starts no + * agent, no session, and no turn. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the standing scope key readers pass as a registry view scope. + * @throws when the preset is unknown or its composition is unusable. + */ + async standingKeyFor(id?: string): Promise<ScopeKey> { + const preset = await this.resolveMountable(id) + return (await this.ensureStanding(preset)).key + } + + /** Resolve (or create, single-flight) the standing mount of one preset. */ + private async ensureStanding(preset: AgentPreset): Promise<StandingMount> { + const pending = this.standing.get(preset.id) + if (pending !== undefined) { + const mounted = await pending + // Files are the only composition editor (authoring is copy/delete), so + // the stamp is what notices an edit: a changed file starts the next + // generation here, for this and later sessions. An unreadable stamp + // serves the current generation — a mount must survive its file + // disappearing, and failing the session over a stat would not. + const current = await compositionStamp(preset.path) + if (current === undefined || sameStamp(mounted.stamp, current)) return mounted + // Guarded delete: a caller that raced this one may have already started + // the next generation, and dropping THAT pointer would fork a third. + if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id) + return this.ensureStanding(preset) + } + const created = (async (): Promise<StandingMount> => { + const key: ScopeKey = { agentPreset: preset.id } + const scope = createScope(this.selfCtx, key) + try { + // Stamped before the file is read: an edit racing the mount makes the + // stamp stale rather than silently current, so the next session + // refreshes instead of trusting a composition older than its stamp. + const stamp = await compositionStamp(preset.path) + if (stamp === undefined) { + throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`) + } + await mountPreset(scope.ctx, preset) + return { key, scope, stamp } + } catch (error) { + this.standing.delete(preset.id) + await scope.dispose() + throw error + } + })() + this.standing.set(preset.id, created) + return created + } +} + +/** The composition file identity one standing generation was mounted from. */ +interface CompositionStamp { + /** Modification time in milliseconds, as `stat` reports it. */ + readonly mtimeMs: number + /** File size in bytes, the tiebreak for edits within one mtime tick. */ + readonly size: number +} + +/** Read one composition file's stamp, or undefined when it cannot be statted. */ +async function compositionStamp(path: string): Promise<CompositionStamp | undefined> { + try { + const { mtimeMs, size } = await stat(path) + return { mtimeMs, size } + } catch { + // Deleted, replaced by an unreadable entry, or otherwise unstattable all + // mean the same to the caller: the file offers no identity to compare. + return undefined + } +} + +/** Whether two stamps name the same file state. */ +function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean { + return a.mtimeMs === b.mtimeMs && a.size === b.size +} + +/** One preset's standing composition. */ +interface StandingMount { + /** Scope key agents are parented to; also the mount's registration scope. */ + readonly key: ScopeKey + /** Disposal boundary; held for whole-tree teardown, never per-session. */ + readonly scope: Scope + /** Stamp of the composition file this generation was mounted from. */ + readonly stamp: CompositionStamp +} + +export default AgentPresets diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts new file mode 100644 index 0000000000..7a08eab7f1 --- /dev/null +++ b/packages/preset/agent-presets/src/invariant.ts @@ -0,0 +1,48 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-presets`. + * @module @deepseek-ai/dsh-agent-presets/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +// Imported through the package name, not `./mount.ts`: a module shared between +// the two build entry points becomes a third chunk that the published `files` +// list does not carry, which `verify-built-package-invariants` rejects. +import { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-presets' + +/** Cordis companion plugin name. */ +export const name = 'agent-presets-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Assert that no installed preset composition reaches the root service realm. + * + * `mountPreset` proves this once, when the subtree settles. A row that + * publishes later — from a timer, or an asynchronous continuation after its + * plugin returned — would escape that one-shot audit, so re-check every live + * mount whenever a service registration changes. + */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.on('internal/service', function (this: Context, name) { + for (const mount of livePresetMounts()) { + const leaked = leakedServices(ctx, mount.fiber) + if (leaked.length === 0) continue + fail( + `preset "${mount.presetId}" published process-global service(s) [${leaked.join(', ')}] ` + + `after its mount was audited (observed while notifying "${name}") — ` + + 'a preset service must sit behind an `isolate` realm or move to the host composition', + ) + } + }, { global: true }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/preset/agent-presets/src/metadata.ts b/packages/preset/agent-presets/src/metadata.ts new file mode 100644 index 0000000000..aa964e0ada --- /dev/null +++ b/packages/preset/agent-presets/src/metadata.ts @@ -0,0 +1,105 @@ +/** + * A preset's display metadata: the name and description a picker shows. + * + * It lives in its own file because the composition is a top-level list of + * plugin rows — YAML cannot carry sibling keys beside it, and faking a + * metadata row would hand the Loader something to load. Keeping it separate + * also keeps the composition exactly what its name says: a Cordis file the + * loader owns and the cordis preset can author. + * + * The file carries display text ONLY. `id` is the directory name and `trust` + * comes from the root a preset was discovered under, so neither is writable + * here — otherwise a locally authored preset could claim to be a shipped one. + * + * Every read failure degrades to no metadata. A preset whose display text is + * missing, malformed, or unreadable still mounts: presentation is not a + * capability, and a broken name must never become an agent that cannot start. + * @module @deepseek-ai/dsh-agent-presets/metadata + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import yaml from 'js-yaml' + +/** The optional display-metadata file beside a preset's composition. */ +export const METADATA_FILE = 'preset.yml' + +/** Display text a preset may publish about itself. */ +export interface PresetMetadata { + /** Human-facing name; falls back to the preset id when absent. */ + readonly name?: string + /** One sentence on what this preset is for. */ + readonly description?: string + /** + * Position within its group; lower comes first. A preset that declares + * none sorts after every preset that does, then by id — so the shipped set + * can read in capability order while authored ones stay alphabetical. + */ + readonly order?: number +} + +/** A non-empty trimmed string, or undefined for anything else. */ +function text(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed === '' ? undefined : trimmed +} + +/** + * Read one preset directory's display metadata. + * + * Absent, unparsable, and wrongly-shaped files are all the same answer — + * empty metadata — because the caller renders a picker, not a diagnostic. + * @param directory - the preset directory. + * @returns the display text the preset published, possibly empty. + */ +export async function readPresetMetadata(directory: string): Promise<PresetMetadata> { + let raw: string + try { + raw = await readFile(join(directory, METADATA_FILE), 'utf8') + } catch { + // Absent is the common case: metadata is optional and most presets, + // including every one authored by duplicating another, carry none. + return {} + } + let parsed: unknown + try { + parsed = yaml.load(raw) + } catch { + // Malformed display text is not worth failing discovery over; the picker + // falls back to the id, and the composition still mounts. + return {} + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} + const record = parsed as Record<string, unknown> + const name = text(record.name) + const description = text(record.description) + const order = typeof record.order === 'number' && Number.isFinite(record.order) + ? record.order + : undefined + return { + ...name === undefined ? {} : { name }, + ...description === undefined ? {} : { description }, + ...order === undefined ? {} : { order }, + } +} + +/** + * Render display metadata as the file's contents. + * + * Absent fields are omitted rather than written empty, so a preset with no + * description does not ship a key that reads as an intentional blank. + * @param metadata - the display text to store. + * @returns the YAML document, or undefined when there is nothing to store. + */ +export function renderPresetMetadata(metadata: PresetMetadata): string | undefined { + const name = text(metadata.name) + const description = text(metadata.description) + const { order } = metadata + if (name === undefined && description === undefined && order === undefined) return undefined + return yaml.dump({ + ...name === undefined ? {} : { name }, + ...description === undefined ? {} : { description }, + ...order === undefined ? {} : { order }, + }, { lineWidth: -1 }) +} diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts new file mode 100644 index 0000000000..fac3319fa1 --- /dev/null +++ b/packages/preset/agent-presets/src/mount.ts @@ -0,0 +1,356 @@ +/** + * Mount one preset composition under an agent's scope context, then prove the + * result is usable before the agent is published. + * + * The scope context is what makes the composition per-session: entry contexts + * chain to the context the subtree was plugged into, so every `ctx.tools` + * and `ctx.systemPrompt` registration inside the preset files into that + * agent's layer and unwinds with it. Two guards make that safe. A row that + * never reached a usable state is rejected, because a directly-plugged subtree + * is absent from `ctx.loader.entries()` and no boot audit covers it. A row that + * published a service into the ROOT realm is rejected, because such a service + * is process-global rather than per-session and the second session mounting the + * same preset collides with the first. + * @module @deepseek-ai/dsh-agent-presets/mount + */ + +import { pathToFileURL } from 'node:url' +import { Context, type Fiber } from 'cordis' +import { Include } from '@cordisjs/plugin-include' +import type { EntryTree } from '@cordisjs/plugin-loader' +import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { PresetMountError, type AgentPreset } from './types.ts' + +/** What one mounted subtree publishes about itself for the audit to read. */ +interface MountedTree { + /** The rows the composition created. */ + readonly tree: EntryTree + /** + * The subtree's own fiber. Captured here rather than taken from + * `ctx.plugin()`, which hands back a thenable `Object.create(fiber)` wrapper + * that is never identical to the fiber appearing in a parent chain. + */ + readonly fiber: Fiber +} + +/** + * Subtrees captured by config identity. A subtree plugged directly (rather than + * created as a loader entry) never links itself to an `Entry`, so this is the + * only handle to the rows it created; config objects are minted per mount, so + * concurrent mounts cannot collide. + */ +const mounted = new WeakMap<object, MountedTree>() + +/** + * The base URL bare specifiers resolve against, per pending mount, keyed by the + * same config object. Recorded before the subtree is plugged, because `Include` + * rewrites its own context's `baseUrl` to the composition's directory and the + * pre-mount value is the only handle on where the harness itself lives. + */ +const harnessBase = new WeakMap<object, string>() + +/** + * Include subclass that publishes its tree and fiber for the audit, and never + * writes to the file it read. + */ +class PresetTree extends Include { + constructor(ctx: Context, config: Include.Config) { + super(ctx, config) + mounted.set(config, { tree: this, fiber: ctx.fiber }) + } + + /** + * Resolve a bare specifier from the harness rather than from the preset. + * + * `EntryTree.import()` resolves against the tree's own `baseUrl`, which + * `Include` sets to the composition's directory. That is right for a + * relative specifier — a preset's own files travel with it — and wrong for + * a package name: a locally authored preset lives under the user's home, + * where Node's upward `node_modules` walk never reaches the harness's own + * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The + * mount records the host composition's base instead, which is inside the + * installed harness, and bare names resolve from there. + * @param name - the module specifier from the row. + * @param getOuterStack - the loader's stack composer for import diagnostics. + * @returns the imported module, or the `cordis:` builtin. + */ + override import(name: string, getOuterStack?: () => string[]): unknown { + const base = harnessBase.get(this.config) + /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */ + if (base === undefined) return super.import(name, getOuterStack) + if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack) + const internal = this.ctx.loader.internal + /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a + hypothetical embedder from losing the row's name in a resolution error. */ + if (internal === undefined) return super.import(name, getOuterStack) + return internal.import(name, base, {}) + } + + /** + * A preset is an input, never a persistence target. + * + * The Loader writes a tree back through this method whenever it decides the + * config changed — a plugin self-disposing is enough, and tearing an agent + * down disposes its whole subtree. Inherited, that rewrites the preset file + * with whatever the dying tree held, which in practice means truncating a + * shipped composition to `[]` the first time a session ends. Persisting a + * preset is also meaningless: nothing here is user state, and the same file + * backs every session that names it. + * + * Dropping the write drops the `loader/config-update` the inherited method + * emits with it. Nothing observes one for a preset subtree today, and a + * future "edit your preset while it runs" flow needs a deliberate + * persistence path rather than this method's return. + */ + override write(): void { + } +} + +/** One preset composition currently installed under some agent. */ +export interface PresetMount { + /** The preset the subtree was composed from. */ + readonly presetId: string + /** The mounted subtree's fiber. */ + readonly fiber: Fiber + /** The standing scope key agents are parented to (undefined only in torn-down records). */ + readonly key: ScopeKey | undefined +} + +const mounts = new Set<PresetMount>() + +/** + * Drop every record whose subtree is gone. + * + * Records are pruned by observation rather than through a disposal hook + * because a subtree can be torn down by its owning agent, by a failed mount, or + * by the whole tree unloading, and a cleared `uid` is what all three share. + * + * Pruning therefore has to happen on a path this module owns. Reading is one + * such path, but not a reliable one: the only production reader is the + * invariant companion's service listener, and `dsh-invariants` is a + * development composition — a shipped host never loads it. Mounting is the + * other, and it is the one every session takes, which bounds the set at one + * generation of dead records rather than one per session ever composed. Each + * record would otherwise retain its whole disposed subtree: the fiber holds + * its config, and that config is the key its `EntryTree` is stored under. + */ +function pruneDisposedMounts(): void { + for (const mount of mounts) { + if (mount.fiber.uid === null) mounts.delete(mount) + } +} + +/** + * Every preset composition still installed, pruning fibers disposed since the + * last read. + * @returns the live mounts. + */ +export function livePresetMounts(): PresetMount[] { + pruneDisposedMounts() + return [...mounts] +} + +/** + * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. + * + * Membership is object identity. `uid` looks like a cheaper key but is a + * per-registry counter, so fibers in two different roots collide on it and a + * subtree in one runtime would be blamed for a service published in another. + * @param fiber - the fiber to locate. + * @param root - the subtree root to test membership against. + * @returns true when `fiber` belongs to `root`'s subtree. + */ +function withinFiber(fiber: Fiber, root: Fiber): boolean { + let current = fiber + while (true) { + if (current === root) return true + const parent = current.parent.fiber + if (parent === current) return false + current = parent + } +} + +/** + * Service names the mounted subtree published into the root realm. + * + * A provider without an `isolate` realm stores its implementation under the + * root's symbol for that name, which is exactly the comparison below; a + * provider inside an `isolate` realm stores under a realm-private symbol and + * is correctly absent here. + * @param ctx - any context of the runtime whose service store is inspected. + * @param mount - the mounted subtree's fiber. + * @returns the leaked service names in lexical order. + */ +export function leakedServices(ctx: Context, mount: Fiber): string[] { + const store = ctx.reflect.store + const rootIsolate = ctx.root[Context.isolate] + const leaked: string[] = [] + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than + clearing it, so an own symbol always resolves; the guard exists only + because the store's index signature is optional. */ + if (impl === undefined) continue + if (!withinFiber(impl.fiber, mount)) continue + if (rootIsolate[impl.name] === key) leaked.push(impl.name) + } + return leaked.sort((left, right) => left.localeCompare(right)) +} + +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes a service behind an `isolate` realm so two sessions + * cannot collide, and an entry-local realm is invisible to everything outside + * the group — including the agent's own scope context and the host. That is + * right for the rows inside the group and wrong for one caller: a request that + * is ABOUT a session but arrives from outside it, which is every browser RPC + * the api-proxy serves. + * + * Ownership is the same relation {@link leakedServices} reads, inverted: there + * it names implementations a subtree published into the ROOT realm, here it + * names the one this subtree published anywhere. Fiber membership is object + * identity for the reason stated on {@link withinFiber}. + * + * This is READ addressing for a caller that already holds the agent. It is not + * a general host handle on a session's internals: a host row that `inject`s a + * service cannot use it, because injection resolves before any session exists + * and has no agent to key by — such a service belongs on the host plane. + * @param ctx - any context of the runtime whose service store is inspected. + * @param agent - the agent whose mounted composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +export function serviceForAgent<K extends string & keyof Context>( + ctx: Context, + agent: { ctx: Context }, + name: K, +): Context[K] | undefined { + // The agent's own key is parented to its preset's standing key; the mount + // is no longer under the agent's fiber, so the search roots at the standing + // mount instead of walking up from the agent. + const agentKey = scopeOf(agent.ctx) + if (agentKey === undefined) return undefined + const standingKey = scopeParentOf(agentKey) + if (standingKey === undefined) return undefined + const mount = livePresetMounts().find(candidate => candidate.key === standingKey) + if (mount === undefined) return undefined + const store = ctx.reflect.store + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ + if (impl === undefined) continue + if (impl.name !== name) continue + if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K] + } + return undefined +} + +/** + * Rows that did not reach a usable state, each rendered as one diagnostic line. + * + * A row whose module failed to import or whose plugin threw already rejects the + * mount through the loader; what remains observable here is a row still waiting + * for a service the composition never supplies. + * @param tree - the mounted subtree. + * @returns one line per unusable row, empty when every enabled row is usable. + */ +export function inactiveRows(tree: EntryTree): string[] { + const lines: string[] = [] + for (const entry of tree.entries()) { + if (entry.disabled) continue + const fiber = entry.fiber + /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed, + so a settled tree never holds an enabled fiber-less entry; the branch exists + only because `Entry.fiber` is declared optional. */ + if (fiber === undefined) { + lines.push(`${entry.options.id} (${entry.options.name}): never started`) + continue + } + const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) + if (missing.length > 0) { + lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`) + } + } + return lines +} + +/** + * The reportable text of a mount failure. + * + * The loader reports several failed rows as one `AggregateError`, whose own + * message names none of them; without flattening, a composition that fails on + * two rows says only "loader entries failed to apply" and the operator has + * nothing to act on. + * @param error - the value the mount rejected with. + * @returns a single-line-per-cause description. + */ +function mountDetail(error: unknown): string { + /* v8 ignore next -- every path into the mount's catch throws an Error: the loader + wraps a row's thrown value before it propagates, and this module's own + rejections are Errors. The fallback keeps a hostile value readable. */ + if (!(error instanceof Error)) return String(error) + if (!(error instanceof AggregateError)) return error.message + return [error.message, ...error.errors.map(cause => `- ${mountDetail(cause)}`)].join('\n') +} + +/** + * Mount `preset` under `agentCtx` and return only once every row is usable. + * + * The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and + * the caller receives no disposer. A rejection leaves nothing mounted. + * @param agentCtx - the agent's scope context, from the agent factory's `setup`. + * @param preset - the resolved preset to compose the agent from. + * @throws when `agentCtx` carries no scope, a row is unusable, or a row + * published a service into the root realm. + */ +export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise<void> { + const scope = scopeOf(agentCtx) + if (scope === undefined) { + throw new Error( + `agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; ` + + 'its registrations would apply to every agent in the process', + ) + } + const config: Include.Config = { path: pathToFileURL(preset.path).href } + // Captured before the subtree exists: the standing scope context still + // carries the host composition's base, which is inside the installed + // harness and is therefore where a row's package name has to resolve from. + /* v8 ignore next -- the Loader sets `baseUrl` on the root before any scoped context derives from it */ + if (agentCtx.baseUrl !== undefined) harnessBase.set(config, agentCtx.baseUrl) + // Before the record this mount is about to add: standing mounts are one per + // preset and live until whole-tree teardown, so pruning here only sweeps + // records of torn-down runtimes (tests; an HMR reload of the roster). + pruneDisposedMounts() + const handle = agentCtx.plugin(PresetTree, config) + try { + await handle.await() + const subtree = mounted.get(config) + /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */ + if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree') + const { tree, fiber } = subtree + const unusable = inactiveRows(tree) + if (unusable.length > 0) { + throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`) + } + const leaked = leakedServices(agentCtx, fiber) + if (leaked.length > 0) { + throw new Error( + `row(s) published process-global service(s) [${leaked.join(', ')}]; ` + + 'a preset service must sit behind an `isolate` realm or move to the host composition', + ) + } + mounts.add({ presetId: preset.id, fiber, key: scopeOf(agentCtx) }) + } catch (error) { + try { + await handle.dispose() + /* v8 ignore next 5 -- teardown of a subtree nothing else references has no + observed failure mode; the guard exists so a teardown error cannot + replace the mount diagnostic the caller needs. */ + } catch { + // Swallows only this subtree's teardown failure. The mount error below is + // the actionable one, and the discarded fiber is unreachable either way. + } + throw new PresetMountError(preset.id, `${mountDetail(error)} (${preset.path})`, { cause: error }) + } +} diff --git a/packages/preset/agent-presets/src/session.ts b/packages/preset/agent-presets/src/session.ts new file mode 100644 index 0000000000..ae3edada27 --- /dev/null +++ b/packages/preset/agent-presets/src/session.ts @@ -0,0 +1,54 @@ +/** + * The session-log record of which preset a session actually runs. + * + * The creation header names the preset a session STARTED with, and it is + * deep-frozen because that is a creation fact. A session may still change + * preset while it is blank, and the effect of that change outlives the blank + * window: the first turn — and every turn after it — runs under the newly + * mounted composition. Recording the change is what keeps the log honest, and + * it is required outright by the repo's model-visible ⟺ logged rule, since the + * preset decides the tool schemas and prompt sections the model sees. + * + * Reconstruction reads {@link resolveSessionPreset}, never the header alone. + * @module @deepseek-ai/dsh-agent-presets/session + */ + +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ + 'agent-preset/selected': { agentPreset: string } + } +} + +/** The minimum a caller must supply to resolve a session's preset. */ +export interface PresetBearingSession { + /** The session's creation header. */ + readonly header: SessionHeader + /** The session's event log, oldest first. */ + readonly events: readonly SessionEvent[] +} + +/** + * The preset a session actually runs, newest selection winning. + * + * The header supplies the creation-time value; every later selection is a + * logged event, so the last one is the answer. Reading the header alone + * rebuilds a switched session under the composition it was created with, not + * the one its history was produced under. + * @param session - the session's header and event log. + * @returns the preset id, or `undefined` when the deployment composes none. + */ +export function resolveSessionPreset(session: PresetBearingSession): string | undefined { + for (let index = session.events.length - 1; index >= 0; index -= 1) { + const event = session.events[index] + if (event?.type === 'agent-preset/selected') return event.data.agentPreset + } + return session.header.agentPreset +} diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts new file mode 100644 index 0000000000..f600d5ca01 --- /dev/null +++ b/packages/preset/agent-presets/src/types.ts @@ -0,0 +1,88 @@ +/** Agent-preset vocabulary shared by discovery, mounting, and consumers. @module @deepseek-ai/dsh-agent-presets/types */ + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' + +/** + * Ids a preset directory may use. + * + * The id becomes a path segment, so this is a containment boundary rather than + * a style rule: `..`, a separator, or an absolute-looking name would place the + * composition outside the root the deployment authorised. Discovery shares it: + * a directory whose name no copy could ever claim is not a preset slot. + */ +export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ + +/** One preset directory that carries a mountable agent composition. */ +export interface AgentPreset { + /** Stable identifier; the preset directory's name. */ + readonly id: string + /** Trust recorded from the root this preset was discovered under. */ + readonly trust: PresetTrust + /** Absolute path of the preset's agent composition file. */ + readonly path: string + /** Display name from the preset's own metadata; absent falls back to {@link id}. */ + readonly name?: string + /** One sentence on what this preset is for, when it published one. */ + readonly description?: string + /** Declared position within its group; absent sorts after those that declare one. */ + readonly order?: number + /** + * Why this preset cannot compose a session, absent when it can. A broken + * preset stays on the roster — hiding it would leave its directory blocking + * the id with nothing to see or delete — but every mounting path refuses it + * up front with this reason instead of failing deep inside the loader. + */ + readonly broken?: string +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** + * No configured root supplies the requested preset. + * + * Separate from a mount failure because the two mean different things to a + * caller: an unknown id is a bad request, while an unusable composition is a + * broken preset the deployment must fix. + */ +export class UnknownPresetError extends Error { + constructor( + /** The id that was requested. */ + readonly presetId: string, + /** Ids the roster does supply, for the caller to offer instead. */ + readonly available: readonly string[], + ) { + super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) + } +} + +/** A preset exists but its composition cannot be installed. */ +export class PresetMountError extends Error { + constructor( + /** The preset whose composition failed. */ + readonly presetId: string, + /** Why it failed, without this package's own message prefix. */ + readonly reason: string, + options?: ErrorOptions, + ) { + super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) + } +} diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts new file mode 100644 index 0000000000..b71776c264 --- /dev/null +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -0,0 +1,293 @@ +/** + * Authoring a preset copies an existing one's directory into the deployment's + * `user` root — copy is the only authoring write, so no caller ever supplies + * composition text. The id is a directory name, so its pattern is a + * containment boundary rather than a style rule; the shipped `.system` set + * stays read-only. + */ + +import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { + COMPOSITION_FILE, copyComposition, METADATA_FILE, +} from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' + +let ctx: Context +let userRoot: string + +/** Hand-craft a preset directory (tests cannot author text through the service). */ +async function seedPreset( + root: string, id: string, options: { composition?: string; metadata?: string; extras?: Record<string, string> } = {}, +): Promise<void> { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), options.composition ?? VALID) + if (options.metadata !== undefined) { + await writeFile(join(root, id, METADATA_FILE), options.metadata) + } + for (const [name, content] of Object.entries(options.extras ?? {})) { + await mkdir(dirname(join(root, id, name)), { recursive: true }) + await writeFile(join(root, id, name), content) + } +} + +beforeEach(async () => { + userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')) + ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: userRoot, trust: 'user' as const }, + ], + }) +}) + +describe('copying a preset', () => { + it('copies a shipped preset into the user root and lists it', async () => { + await ctx.agentPresets.copy('standard', 'mine') + + expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8')) + .toBe(await ctx.agentPresets.read('standard')) + const listed = await ctx.agentPresets.list() + expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user') + }) + + it('copies the whole directory, execute bits kept and group/other stripped', async () => { + await seedPreset(userRoot, 'source', { + extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' }, + }) + await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + + await ctx.agentPresets.copy('source', 'mine') + + expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n') + // A preset may ship runnable helpers; the copy keeps them runnable for the + // owner while withdrawing the world-readability of the install. + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) + expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + }) + + it('keeps the source description but never its name or order', async () => { + await seedPreset(userRoot, 'source', { metadata: 'name: 源模式\ndescription: 只做检索。\norder: 1\n' }) + + await ctx.agentPresets.copy('source', 'mine') + + // Two rows presenting identically is how a roster stops being a chooser, + // and the shipped set's declared order is not the copy's to claim. + const metadata = await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8') + expect(metadata).toContain('description: 只做检索。') + expect(metadata).not.toContain('name:') + expect(metadata).not.toContain('order:') + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')) + .toMatchObject({ description: '只做检索。' }) + }) + + it('stores the display name the author supplied', async () => { + await ctx.agentPresets.copy('standard', 'mine', '我的模式') + + expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8')).toContain('name: 我的模式') + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')) + .toMatchObject({ name: '我的模式' }) + }) + + it('publishes no metadata file when there is nothing to publish', async () => { + await seedPreset(userRoot, 'source') + + await ctx.agentPresets.copy('source', 'mine') + + // An empty metadata document would read as an intentional blank name; + // absence is what "this preset publishes no display text" looks like. + expect(existsSync(join(userRoot, 'mine', METADATA_FILE))).toBe(false) + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')?.name).toBeUndefined() + }) + + it('refuses an id that could escape the preset root', async () => { + for (const id of ['../escape', 'a/b', '/abs', '..', 'Upper']) { + await expect(ctx.agentPresets.copy('standard', id)).rejects.toThrow(/must match/) + } + // Nothing was created for any of them. + expect(existsSync(join(userRoot, 'escape'))).toBe(false) + }) + + it('refuses an id the roster already supplies, shipped ones included', async () => { + await ctx.agentPresets.copy('standard', 'mine') + + await expect(ctx.agentPresets.copy('standard', 'mine')).rejects.toThrow(/already exists/) + // A user directory named like a shipped preset would be shadowed by it. + await expect(ctx.agentPresets.copy('standard', 'minimal')).rejects.toThrow(/already exists/) + }) + + it('refuses a directory that occupies the name without being a preset', async () => { + await mkdir(join(userRoot, 'occupied'), { recursive: true }) + await writeFile(join(userRoot, 'occupied', 'README.txt'), 'nope\n') + + // Discovery does not list it (no composition file), so only the disk + // check can refuse it with a readable error instead of a filesystem code. + await expect(ctx.agentPresets.copy('standard', 'occupied')).rejects.toThrow(/already exists/) + expect(await readFile(join(userRoot, 'occupied', 'README.txt'), 'utf8')).toBe('nope\n') + }) + + it('reports an unknown source rather than creating anything', async () => { + await expect(ctx.agentPresets.copy('never-existed', 'mine')).rejects.toThrow(/not found/) + expect(existsSync(join(userRoot, 'mine'))).toBe(false) + }) + + it('leaves nothing behind when the copy itself fails', async () => { + const source = { + id: 'gone', + trust: 'user' as const, + path: join(userRoot, 'gone', COMPOSITION_FILE), + } + + // The source vanished between resolve and copy: the half-made target is + // rolled back rather than left invisible to discovery. + await expect(copyComposition( + [{ path: userRoot, trust: 'user' as const }], source, 'mine', + )).rejects.toThrow() + expect(existsSync(join(userRoot, 'mine'))).toBe(false) + }) +}) + +describe('deleting a preset', () => { + it('removes a locally authored one', async () => { + await ctx.agentPresets.copy('standard', 'mine') + + await ctx.agentPresets.remove('mine') + + expect(existsSync(join(userRoot, 'mine'))).toBe(false) + expect((await ctx.agentPresets.list()).some(preset => preset.id === 'mine')).toBe(false) + }) + + it('refuses to delete a shipped one', async () => { + await expect(ctx.agentPresets.remove('standard')) + .rejects.toThrow(/ships with the deployment/) + }) + + it('reports an unknown id rather than silently succeeding', async () => { + await expect(ctx.agentPresets.remove('never-existed')).rejects.toThrow(/not found/) + }) +}) + +describe('a deployment with more than one user root', () => { + it('refuses to delete a preset the writable root does not own', async () => { + const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-')) + await seedPreset(second, 'elsewhere') + const layered = new Context() + layered.baseUrl = pathToFileURL(FIXTURES).href + '/' + await layered.plugin(Loader) + layered.loader.builtins.include = Include + await layered.plugin(AgentPresets, { + default: 'standard', + roots: [ + { path: userRoot, trust: 'user' as const }, + { path: second, trust: 'user' as const }, + ], + }) + + // Writes go to the first user root, so a preset discovered from a later + // one is `user` trust yet outside what deletion is allowed to touch — + // `rm -r` on a directory this root does not own is the failure to avoid. + await expect(layered.agentPresets.remove('elsewhere')) + .rejects.toThrow(/does not live under the writable preset root/) + expect(existsSync(join(second, 'elsewhere'))).toBe(true) + }) +}) + +describe('a deployment with no writable root', () => { + it('says authoring is unavailable rather than guessing a directory', async () => { + const readOnly = new Context() + readOnly.baseUrl = pathToFileURL(FIXTURES).href + '/' + await readOnly.plugin(Loader) + readOnly.loader.builtins.include = Include + await readOnly.plugin(AgentPresets, { + default: 'standard', + roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + }) + + expect(readOnly.agentPresets.authorable).toBe(false) + await expect(readOnly.agentPresets.copy('standard', 'mine')) + .rejects.toThrow(/no user-writable preset root/) + }) +}) + +describe('a user root that does not exist yet', () => { + it('is created by the first copy', async () => { + const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset') + const fresh = new Context() + fresh.baseUrl = pathToFileURL(FIXTURES).href + '/' + await fresh.plugin(Loader) + fresh.loader.builtins.include = Include + await fresh.plugin(AgentPresets, { + default: 'standard', + roots: [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: absent, trust: 'user' as const }, + ], + }) + + await fresh.agentPresets.copy('standard', 'mine') + + expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8')) + .toBe(await fresh.agentPresets.read('standard')) + }) +}) + +describe('display metadata beside a composition', () => { + it('keeps a composition mountable when its metadata is unreadable', async () => { + await ctx.agentPresets.copy('standard', 'mine') + await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n') + + // Presentation is not capability: discovery still yields the preset. + const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine') + expect(listed?.name).toBeUndefined() + expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' }) + }) +}) + +describe('the on-disk occupancy backstop', () => { + it('refuses a directory the roster cannot see', async () => { + // The service's roster check sees every id-shaped directory now, so this + // is the race backstop: a directory appearing between the roster read and + // the copy still gets the readable refusal, not a filesystem error code. + await mkdir(join(userRoot, 'raced'), { recursive: true }) + const source = await ctx.agentPresets.resolve('standard') + + await expect(copyComposition( + [{ path: userRoot, trust: 'user' as const }], source, 'raced', + )).rejects.toThrow(/already exists/) + }) +}) + +describe('a ghost directory under the user root', () => { + it('lists broken, blocks its id, and clears through remove', async () => { + // The classic hand-edit: the composition file was deleted, the directory + // stayed. It must not vanish from the roster — its id is still taken, so + // there has to be something to see and delete. + await mkdir(join(userRoot, 'ghost'), { recursive: true }) + await writeFile(join(userRoot, 'ghost', 'README.txt'), 'composition deleted by hand\n') + + const ghost = (await ctx.agentPresets.list()).find(preset => preset.id === 'ghost') + expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/) + await expect(ctx.agentPresets.copy('standard', 'ghost')).rejects.toThrow(/already exists/) + + // remove is the way out the roster row offers; the id is claimable again. + await ctx.agentPresets.remove('ghost') + expect(existsSync(join(userRoot, 'ghost'))).toBe(false) + await ctx.agentPresets.copy('standard', 'ghost') + expect((await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')?.broken).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts new file mode 100644 index 0000000000..55845c2251 --- /dev/null +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -0,0 +1,197 @@ +import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } +const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } + +describe('display order', () => { + it('puts declared order first, then everything else by id', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) + for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') + await writeFile(join(root, id, 'preset.yml'), `order: ${String(order)}\n`) + } + for (const id of ['bravo', 'yankee']) { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') + } + + const found = await scanRoot({ path: root, trust: 'system' }) + + // The shipped set reads by capability; presets that declare nothing stay + // alphabetical behind them rather than interleaving unpredictably. + expect(found.map(preset => preset.id)).toEqual(['zulu', 'alpha', 'bravo', 'yankee']) + }) + + it('breaks a tie between equal declared orders by id', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-')) + for (const id of ['yankee', 'alpha']) { + await mkdir(join(root, id), { recursive: true }) + await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') + await writeFile(join(root, id, 'preset.yml'), 'order: 1\n') + } + + const found = await scanRoot({ path: root, trust: 'system' }) + + // Two presets claiming the same slot must still list in a stable order: + // a directory-scan order would reshuffle the picker between reads. + expect(found.map(preset => preset.id)).toEqual(['alpha', 'yankee']) + }) +}) + +describe('preset discovery', () => { + it('reports one preset per directory holding a composition, ordered by id', async () => { + const found = await scanRoot(SYSTEM) + + expect(found.map(preset => preset.id)).toEqual(['minimal', 'standard']) + expect(found[0]).toEqual({ + id: 'minimal', + trust: 'system', + path: join(SYSTEM.path, 'minimal', COMPOSITION_FILE), + }) + }) + + it('reports a directory with no composition as a broken preset slot', async () => { + const found = await scanRoot(USER) + + // The directory still occupies its id — a copy to that name is refused — + // so hiding it would leave nothing to see or delete. It surfaces broken. + const ghost = found.find(preset => preset.id === 'not-a-preset') + expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/) + }) + + it('skips a directory whose name no preset id could ever claim', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-')) + await mkdir(join(root, '.hidden')) + await mkdir(join(root, 'Has_Caps')) + await mkdir(join(root, 'usable')) + await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n') + + const found = await scanRoot({ path: root, trust: 'user' }) + + // `.hidden` and `Has_Caps` cannot collide with any copy target, so + // reporting tool residue as broken presets would only train users to + // ignore the marker. + expect(found.map(preset => preset.id)).toEqual(['usable']) + }) + + it('records the root trust on every preset it discovers', async () => { + const found = await scanRoot(USER) + + expect(found.every(preset => preset.trust === 'user')).toBe(true) + }) + + it('lets the earlier root win a duplicate id', async () => { + const found = await discoverPresets([SYSTEM, USER]) + + const standard = found.filter(preset => preset.id === 'standard') + expect(standard).toHaveLength(1) + expect(standard[0]?.trust).toBe('system') + }) + + it('treats an absent root as supplying no presets', async () => { + const found = await scanRoot({ path: join(FIXTURES, 'no-such-root'), trust: 'user' }) + + expect(found).toEqual([]) + }) + + it('ignores a plain file sitting beside the preset directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + await writeFile(join(root, 'stray.yml'), '- id: x\n') + await mkdir(join(root, 'real')) + await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n') + + const found = await scanRoot({ path: root, trust: 'user' }) + + expect(found.map(preset => preset.id)).toEqual(['real']) + }) + + it('reports a root it cannot read rather than treating it as empty', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + const notADirectory = join(root, 'file-as-root') + await writeFile(notADirectory, 'not a directory\n') + + await expect(scanRoot({ path: notADirectory, trust: 'user' })) + .rejects.toThrow(/cannot read preset root/) + }) + + it('expands a leading tilde in a root path', async () => { + // `~` alone resolves to the home directory, which exists but holds no + // preset directories; the point is that it did not throw on a literal `~`. + const found = await scanRoot({ path: '~/.dsh-agent-presets-absent', trust: 'user' }) + + expect(found).toEqual([]) + }) +}) + +describe('composition health', () => { + /** One directory under a fresh root holding `composition`, scanned. */ + async function scanned(composition: string): Promise<string | undefined> { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-')) + await mkdir(join(root, 'probe')) + await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) + const [preset] = await scanRoot({ path: root, trust: 'user' }) + return preset?.broken + } + + it('reports unparsable YAML with the parser\'s reason', async () => { + expect(await scanned('- id: x\n name: [unclosed\n')).toMatch(/not valid YAML/) + }) + + it('reports a composition that is not a list of rows', async () => { + expect(await scanned('name: not-a-list\n')).toMatch(/top-level list of plugin rows/) + }) + + it('reports the first row that names no plugin, by position', async () => { + expect(await scanned('- id: ok\n name: some-plugin\n- id: broken\n')) + .toMatch(/row 2 names no plugin/) + }) + + it('reports a row that is not a map at all', async () => { + expect(await scanned('- just-a-string\n')).toMatch(/row 1 is not a plugin row/) + }) + + it('descends into a group\'s own row list', async () => { + const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n' + expect(await scanned(composition)).toMatch(/row 1 row 1 names no plugin/) + }) + + it('reports a group whose config is not a list', async () => { + const composition = '- id: grp\n name: cordis:group\n group: true\n config: not-a-list\n' + expect(await scanned(composition)).toMatch(/group row 1 must hold a list/) + }) + + it('accepts a group whose own list is healthy', async () => { + const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n name: some-plugin\n' + expect(await scanned(composition)).toBeUndefined() + }) + + it('reports a composition that stats but cannot be read', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-')) + await mkdir(join(root, 'sealed')) + const path = join(root, 'sealed', COMPOSITION_FILE) + await writeFile(path, '[]\n') + await chmod(path, 0o000) + + const [preset] = await scanRoot({ path: root, trust: 'user' }) + + expect(preset?.broken).toMatch(/cannot be read/) + }) + + it('accepts the loader dialect, !!js scalars included', async () => { + // Health must never call a composition broken that the loader accepts: + // `!!js` is the loader's own extension, so it parses here too. + const composition = '- id: x\n name: some-plugin\n config:\n value: !!js "1 + 1"\n' + expect(await scanned(composition)).toBeUndefined() + }) + + it('accepts an empty list', async () => { + expect(await scanned('[]\n')).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js b/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js new file mode 100644 index 0000000000..b7b67be5d6 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js @@ -0,0 +1,20 @@ +// A preset row: registers one tool and one prompt section, both named from +// config. Import-free on purpose — the Loader resolves entry modules through +// Node's ESM resolver, which cannot see this workspace's TypeScript sources. +export const name = 'contribute' +export const inject = ['tools', 'systemPrompt'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) + ctx.effect(() => ctx.systemPrompt.section({ + name: `preset:${config.tool}`, + order: 10, + text: `section for ${config.tool}`, + })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js b/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js new file mode 100644 index 0000000000..b30e37356b --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js @@ -0,0 +1,5 @@ +// Publishes a service with no `isolate` realm, so it lands in the ROOT realm. +export const name = 'global-service' +export function apply(ctx, config) { + ctx.effect(() => ctx.reflect.provide(config.service, { label: config.label })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js b/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js new file mode 100644 index 0000000000..d0381a9979 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js @@ -0,0 +1,6 @@ +// Publishes into the ROOT realm only after its plugin body returned, escaping +// the one-shot mount audit. Exercises the package invariant. +export const name = 'late-service' +export function apply(ctx, config) { + globalThis.__PUBLISH_LATE__ = () => ctx.effect(() => ctx.reflect.provide(config.service, { label: 'late' })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js b/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js new file mode 100644 index 0000000000..b4f732eeac --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js @@ -0,0 +1,5 @@ +// Waits forever for a service the composition never supplies: the row stays +// pending rather than failing, which only the mount audit can catch. +export const name = 'needs-missing' +export const inject = ['serviceThatDoesNotExist'] +export function apply() {} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js b/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js new file mode 100644 index 0000000000..97c01f95a4 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js @@ -0,0 +1,9 @@ +// Disposes itself once active. The Loader treats a self-disposing entry as a +// config change and writes the tree back through `EntryTree.write()`, which is +// the exact path that once truncated a preset file to `[]`. +export const name = 'self-dispose' +export function apply(ctx) { + globalThis.__SELF_DISPOSED__ = new Promise((resolve) => { + setTimeout(() => { ctx.fiber.dispose(); resolve(undefined) }, 0) + }) +} diff --git a/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml new file mode 100644 index 0000000000..ebd0a74c33 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml @@ -0,0 +1,4 @@ +- id: beta + name: ../../plugins/contribute.js + config: + tool: beta diff --git a/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml new file mode 100644 index 0000000000..9a434aec6e --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml @@ -0,0 +1,12 @@ +# Shipped preset: one tool plus its guidance section. +- id: alpha + name: ../../plugins/contribute.js + config: + tool: alpha + +# A row switched off in the composition stays off without failing the mount. +- id: alpha-extra + name: ../../plugins/contribute.js + disabled: true + config: + tool: alpha-extra diff --git a/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml new file mode 100644 index 0000000000..ae9baeee11 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml @@ -0,0 +1,6 @@ +- id: ok + name: ../../plugins/contribute.js + config: + tool: ok +- id: missing + name: ../../plugins/does-not-exist.js diff --git a/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml new file mode 100644 index 0000000000..ccb3a9037c --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml @@ -0,0 +1,9 @@ +# Accepted: the same provider behind an entry-local realm never reaches the +# root realm, so it is per-session rather than process-global. +- id: svc + name: ../../plugins/global-service.js + isolate: + fixtureIsolatedSvc: true + config: + service: fixtureIsolatedSvc + label: ISOLATED diff --git a/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml new file mode 100644 index 0000000000..895268e67b --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml @@ -0,0 +1,6 @@ +# Publishes into the root realm only after the mount audit ran, which only the +# package invariant can catch. +- id: late + name: ../../plugins/late-service.js + config: + service: fixtureLateSvc diff --git a/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml new file mode 100644 index 0000000000..f95329ce46 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml @@ -0,0 +1,13 @@ +# Rejected: publishes services into the root realm, which would be +# process-global rather than per-session. Two rows, so the diagnostic has to +# order the names it reports. +- id: leak-z + name: ../../plugins/global-service.js + config: + service: zzzFixtureLeakedSvc + label: LEAKED-Z +- id: leak-a + name: ../../plugins/global-service.js + config: + service: aaaFixtureLeakedSvc + label: LEAKED-A diff --git a/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt b/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt new file mode 100644 index 0000000000..b4a2550351 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt @@ -0,0 +1 @@ +placeholder, not a preset diff --git a/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml new file mode 100644 index 0000000000..67f7ffb09a --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml @@ -0,0 +1,2 @@ +- id: waits + name: ../../plugins/needs-missing.js diff --git a/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml new file mode 100644 index 0000000000..4cfbbcb20c --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml @@ -0,0 +1,5 @@ +# Same id as the shipped preset: proves the earlier root wins. +- id: shadowed + name: ../../plugins/contribute.js + config: + tool: shadowed diff --git a/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml new file mode 100644 index 0000000000..1533565f58 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/two-broken/agent.cordis.yml @@ -0,0 +1,7 @@ +# Two rows that cannot load: the Loader reports several failed entries as one +# AggregateError whose own message names none of them, so this fixture is what +# proves the mount diagnostic flattens the causes. +- id: first-missing + name: ../../plugins/does-not-exist.js +- id: second-missing + name: ../../plugins/also-missing.js diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts new file mode 100644 index 0000000000..17d89813c3 --- /dev/null +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -0,0 +1,87 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { describe, expect, it } from 'vitest' +import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: join(FIXTURES, 'user'), trust: 'user' as const }, +] + +async function harness(): Promise<Context> { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentPresetsInvariant) + return ctx +} + +describe('agent-presets invariants', () => { + it('keeps the standing composition alive across the agents that joined it', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + sessionId: SessionId('inv-live'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') + + // A standing mount survives its agents: the composition a session joined + // is shared, so one session ending must not strip it from the next. + await handle.dispose() + expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') + + // A second agent reuses the same mount rather than adding one. + await ctx.agents.create({ + sessionId: SessionId('inv-live-2'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(1) + + // Whole-tree teardown is the boundary that does reclaim it. + await ctx.fiber.dispose() + expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard') + }) + + it('rejects a composition that publishes a process-global service after its audit', async () => { + const ctx = await harness() + await ctx.agents.create({ + sessionId: SessionId('inv-late'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'late'), + }) + const publishLate = (globalThis as { __PUBLISH_LATE__?: () => void }).__PUBLISH_LATE__ + expect(publishLate).toBeTypeOf('function') + + expect(() => { publishLate?.() }).toThrow(/published process-global service\(s\) \[fixtureLateSvc\]/) + }) + + it('stays quiet while every composition keeps its services out of the root realm', async () => { + const ctx = await harness() + + await expect(ctx.agents.create({ + sessionId: SessionId('inv-isolated'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'), + })).resolves.toBeDefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts new file mode 100644 index 0000000000..8bdabe1c59 --- /dev/null +++ b/packages/preset/agent-presets/tests/metadata.spec.ts @@ -0,0 +1,114 @@ +/** + * Display metadata is presentation, never capability: every way of getting it + * wrong degrades to "this preset has no display text" rather than to a + * preset that cannot be discovered or mounted. It also cannot carry identity + * — `id` is the directory and `trust` is the root, so neither is readable + * from the file a user can write. + */ + +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts' + +/** A preset directory holding exactly the given metadata text. */ +async function presetDir(content?: string): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-')) + await mkdir(dir, { recursive: true }) + if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content) + return dir +} + +describe('reading display metadata', () => { + it('reads a name and a description', async () => { + const dir = await presetDir('name: 标准模式\ndescription: 完整的编码 agent。\n') + + expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', description: '完整的编码 agent。' }) + }) + + it('treats an absent file as no metadata', async () => { + // The common case: every preset authored by duplicating another starts + // without one, and a picker simply falls back to the id. + expect(await readPresetMetadata(await presetDir())).toEqual({}) + }) + + it('treats malformed YAML as no metadata', async () => { + const dir = await presetDir('name: [unclosed\n') + + // Display text is not worth failing discovery over — the composition + // beside it still mounts. + expect(await readPresetMetadata(dir)).toEqual({}) + }) + + it.each([ + ['a list', '- name: x\n'], + ['a scalar', 'just a string\n'], + ['an empty document', ''], + ])('treats %s as no metadata', async (_label, content) => { + expect(await readPresetMetadata(await presetDir(content))).toEqual({}) + }) + + it('ignores fields that are not text', async () => { + const dir = await presetDir('name: 42\ndescription:\n nested: true\n') + + expect(await readPresetMetadata(dir)).toEqual({}) + }) + + it('ignores blank text rather than showing an empty name', async () => { + const dir = await presetDir('name: " "\ndescription: ""\n') + + expect(await readPresetMetadata(dir)).toEqual({}) + }) + + it('trims surrounding whitespace', async () => { + const dir = await presetDir('name: " 极简模式 "\n') + + expect(await readPresetMetadata(dir)).toEqual({ name: '极简模式' }) + }) + + it('reads a declared order', async () => { + const dir = await presetDir('name: 标准模式\norder: 1\n') + + expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', order: 1 }) + }) + + it('ignores an order that is not a finite number', async () => { + expect(await readPresetMetadata(await presetDir('order: first\n'))).toEqual({}) + expect(await readPresetMetadata(await presetDir('order: .inf\n'))).toEqual({}) + }) + + it('cannot carry identity or trust', async () => { + const dir = await presetDir('name: mine\nid: standard\ntrust: system\n') + + // A locally authored preset writing `trust: system` must not become a + // shipped one; identity comes from the directory and the root it sits in. + expect(await readPresetMetadata(dir)).toEqual({ name: 'mine' }) + }) +}) + +describe('rendering display metadata', () => { + it('round-trips through a read', async () => { + const rendered = renderPresetMetadata({ name: '创造模式', description: '可以改自己的组装。' }) + const dir = await presetDir(rendered) + + expect(await readPresetMetadata(dir)).toEqual({ name: '创造模式', description: '可以改自己的组装。' }) + }) + + it('stores a declared order', () => { + expect(renderPresetMetadata({ name: '标准模式', order: 1 })).toBe('name: 标准模式\norder: 1\n') + }) + + it('omits an absent field rather than writing it blank', () => { + expect(renderPresetMetadata({ name: '极简模式' })).toBe('name: 极简模式\n') + // Description without a name is legal too: the picker falls back to the id. + expect(renderPresetMetadata({ description: '只做检索。' })).toBe('description: 只做检索。\n') + }) + + it('renders nothing when there is nothing to store', () => { + // Clearing both fields removes the file; an empty document would read as + // an intentional blank name. + expect(renderPresetMetadata({})).toBeUndefined() + expect(renderPresetMetadata({ name: ' ', description: '' })).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts new file mode 100644 index 0000000000..b4988331cc --- /dev/null +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -0,0 +1,574 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { + COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, +} from '@deepseek-ai/dsh-agent-presets' +import type { Config } from '@deepseek-ai/dsh-agent-presets' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' + +declare module 'cordis' { + interface Context { + /** Published by the `isolated` fixture preset behind an entry-local realm. */ + fixtureIsolatedSvc: { label: string } + } +} + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: join(FIXTURES, 'user'), trust: 'user' as const }, +] + +/** + * A composition carrying the registries a preset contributes to, plus the + * preset roster. + * @param roster - roster config, defaulting to the fixture roots. + * @returns the booted context. + */ +async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise<Context> { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, roster) + return ctx +} + +/** Create one agent composed from `presetId`, exactly as a factory `setup` would. */ +async function agentOn(ctx: Context, id: string, presetId?: string): Promise<Agent> { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId), + }) + return handle.agent +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +/** Every service registration in the runtime, regardless of which realm holds it. */ +function providedServiceNames(ctx: Context): string[] { + const store = ctx.reflect.store + return Object.getOwnPropertySymbols(store) + .map(key => store[key]?.name) + .filter((name): name is string => name !== undefined) +} + +/** Whether the root realm maps `name` to a live registration. */ +function rootResolves(ctx: Context, name: string): boolean { + const key = ctx.root[Context.isolate][name] + return key !== undefined && ctx.reflect.store[key] !== undefined +} + +let ctx: Context +beforeEach(async () => { + ctx = await harness() +}) + +describe('composing an agent from a preset', () => { + it('gives each session only its own preset\'s tools', async () => { + const alpha = await agentOn(ctx, 'sess-alpha', 'standard') + const beta = await agentOn(ctx, 'sess-beta', 'minimal') + + expect(toolNames(ctx, alpha)).toEqual(['alpha']) + expect(toolNames(ctx, beta)).toEqual(['beta']) + expect(toolNames(ctx)).toEqual([]) + }) + + it('scopes prompt sections and assembled schemas to the same session', async () => { + const alpha = await agentOn(ctx, 'sess-alpha', 'standard') + const beta = await agentOn(ctx, 'sess-beta', 'minimal') + + const alphaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(alpha)) + const betaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(beta)) + + expect(alphaPrompt.sections.map(section => section.name)).toContain('preset:alpha') + expect(alphaPrompt.sections.map(section => section.name)).not.toContain('preset:beta') + expect(betaPrompt.sections.map(section => section.name)).toContain('preset:beta') + expect(alphaPrompt.tools.map(schema => schema.name)).toEqual(['alpha']) + }) + + it('mounts the default preset when the caller names none', async () => { + const agent = await agentOn(ctx, 'sess-default') + + expect(toolNames(ctx, agent)).toEqual(['alpha']) + }) + + it('lets two sessions share one preset without colliding', async () => { + const first = await agentOn(ctx, 'sess-first', 'standard') + const second = await agentOn(ctx, 'sess-second', 'standard') + + expect(toolNames(ctx, first)).toEqual(['alpha']) + expect(toolNames(ctx, second)).toEqual(['alpha']) + }) + + it('unwinds one session\'s composition without touching another\'s', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-gone'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const survivor = await agentOn(ctx, 'sess-stays', 'minimal') + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + + await handle.dispose() + + expect(ctx.agents.get(SessionId('sess-gone'))).toBeUndefined() + expect(toolNames(ctx, survivor)).toEqual(['beta']) + expect(toolNames(ctx)).toEqual([]) + }) +}) + +describe('rejecting a composition that cannot be used', () => { + it('refuses to mount into a context that carries no agent scope', async () => { + await expect(ctx.agentPresets.mount(ctx, 'standard')) + .rejects.toThrow(/unscoped context/) + }) + + it('rolls the whole agent back when a row fails to load', async () => { + await expect(agentOn(ctx, 'sess-broken', 'broken')).rejects.toThrow(/failed to mount/) + + expect(ctx.agents.get(SessionId('sess-broken'))).toBeUndefined() + expect(toolNames(ctx)).toEqual([]) + }) + + it('names every failed row, not just the count', async () => { + // The Loader folds several failed rows into one AggregateError whose own + // message names none of them; unflattened, the operator is told only that + // "loader entries failed to apply" and has nothing to act on. + await expect(agentOn(ctx, 'sess-two-broken', 'two-broken')) + .rejects.toThrow(/first-missing[\s\S]*second-missing/) + }) + + it('names the unresolved service when a row never activates', async () => { + await expect(agentOn(ctx, 'sess-pending', 'pending')) + .rejects.toThrow(/waiting for serviceThatDoesNotExist/) + }) + + it('rejects a row that publishes a process-global service', async () => { + await expect(agentOn(ctx, 'sess-leaky', 'leaky')) + .rejects.toThrow(/process-global service\(s\) \[aaaFixtureLeakedSvc, zzzFixtureLeakedSvc\]/) + + // The rejected subtree is fully unwound, so its registrations are gone from + // the store rather than merely unreachable. + expect(providedServiceNames(ctx)).not.toContain('aaaFixtureLeakedSvc') + expect(providedServiceNames(ctx)).not.toContain('zzzFixtureLeakedSvc') + }) + + it('accepts the same provider behind an isolate realm', async () => { + const agent = await agentOn(ctx, 'sess-isolated', 'isolated') + + expect(agent.id).toBe(SessionId('sess-isolated')) + // The provider ran, but under a realm-private symbol the root cannot reach. + expect(providedServiceNames(ctx)).toContain('fixtureIsolatedSvc') + expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) + }) + + it('addresses the standing instance of a realm-private service through either agent', async () => { + const first = await agentOn(ctx, 'sess-reach-a', 'isolated') + const second = await agentOn(ctx, 'sess-reach-b', 'isolated') + + // The realm keeps the service out of every host context, so a caller + // holding the agent is how a request from OUTSIDE the session reads the + // instance it is about. + expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) + const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc') + const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc') + expect(mine).toBeDefined() + // ONE composition per preset: both agents joined the same standing mount, + // so they address the same instance — sessions stay apart inside it by + // the plugin's own Session/Agent keying, not by instance count. + expect(theirs).toBe(mine) + }) + + it('answers undefined for a service the agent\'s preset does not mount', async () => { + // The isolated preset's standing instance exists in the same runtime, so + // the lookup finds the NAME and must still refuse it: the instance lives + // under another mount's fiber, not this agent's composition. + await agentOn(ctx, 'sess-reach-other', 'isolated') + const agent = await agentOn(ctx, 'sess-reach-none', 'standard') + + expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined() + }) + + it('answers undefined for an agent outside the scope machinery', async () => { + // Unscoped, scoped-but-unparented, and parented to a key no live mount + // owns are the three ways a context can fail to name a standing mount; + // each is an answer, not a throw, because the caller asked a question. + expect(serviceForAgent(ctx, { ctx }, 'fixtureIsolatedSvc')).toBeUndefined() + const loner = createScope(ctx, { test: 'loner' }) + expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() + const orphan = createScope(ctx, { test: 'orphan' }) + bindScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' }) + expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined() + }) + + it('refuses to mount a preset directly into an unscoped context', async () => { + // The service's own mount() guards this before delegating; the exported + // function is callable on its own, so the boundary holds there too. + const preset = await ctx.agentPresets.resolve('standard') + + await expect(mountPreset(ctx, preset)).rejects.toThrow(/unscoped context/) + }) + + it('reports the known ids when a preset is unknown', async () => { + await expect(ctx.agentPresets.resolve('nope')) + .rejects.toThrow(/preset "nope" not found \(available: .*standard/) + }) +}) + +describe('the preset roster', () => { + it('lists every root\'s presets with the earlier root winning', async () => { + const listed = await ctx.agentPresets.list() + + // `not-a-preset` is the fixture ghost: no composition file, listed broken. + expect(listed.map(preset => preset.id).sort()) + .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'not-a-preset', 'pending', 'standard', 'two-broken']) + expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system') + expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/) + }) + + it('exposes the configured default id', () => { + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) + +describe('composing from a broken preset', () => { + /** A roster whose only user preset carries `composition`. */ + async function rosterWith(composition: string): Promise<Context> { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) + await mkdir(join(root, 'damaged')) + await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] }) + } + + it('refuses the mount up front with the discovery-reported reason', async () => { + const scoped = await rosterWith('- id: x\n name: [unclosed\n') + + // The refusal happens before the loader ever sees the file, so every + // unloadable shape gets the same early PresetMountError — and a rejected + // setup rolls the whole agent creation back. + await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError) + await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/) + expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0) + }) + + it('refuses the standing key a cold reader would mount by', async () => { + const scoped = await rosterWith('rows: not-a-list\n') + + await expect(scoped.agentPresets.standingKeyFor('damaged')) + .rejects.toThrow(/top-level list of plugin rows/) + }) + + it('still resolves the broken row for the surfaces that manage it', async () => { + const scoped = await rosterWith('- id: x\n name: [unclosed\n') + + // Deleting and reporting need the row; only composing refuses it. + expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/) + }) +}) + +describe('a roster with nothing in it', () => { + it('says so instead of naming an empty list of candidates', async () => { + const bare = new Context() + await bare.plugin(Loader) + await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + + await expect(bare.agentPresets.resolve()) + .rejects.toThrow(/preset "standard" not found \(available: none\)/) + }) +}) + +describe('the preset file is an input, never a persistence target', () => { + it('survives a row that disposes itself, which makes the Loader persist a tree', async () => { + // The preset lives in a temp root, not under `fixtures/`: without the + // `write()` override the Loader REWRITES the composition it read, so a + // committed fixture would be mutated by the very run that proves the bug + // and every later run would compare against the damaged file and pass. + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-')) + const dir = join(root, 'self-disposing') + await mkdir(dir) + const path = join(dir, COMPOSITION_FILE) + const composition = [ + '- id: tool-kept', + ` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`, + ' config:', + ' tool: kept', + '- id: goes-away', + ` name: ${join(FIXTURES, 'plugins', 'self-dispose.js')}`, + '', + ].join('\n') + await writeFile(path, composition) + + const scoped = new Context() + scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' + await scoped.plugin(Loader) + scoped.loader.builtins.include = Include + await scoped.plugin(LlmService) + await scoped.plugin(SessionStore) + await scoped.plugin(SystemPrompt, { persona: '' }) + await scoped.plugin(ToolRegistry) + await scoped.plugin(AgentRegistry) + await scoped.plugin(AgentLoop, { agents: [] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + + await scoped.agents.create({ + sessionId: SessionId('sess-self-dispose'), + setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx), + }) + await (globalThis as { __SELF_DISPOSED__?: Promise<unknown> }).__SELF_DISPOSED__ + // Slack past the deterministic signal above, not a race the number has to + // win. The write rides the Loader's fiber-unload listener, which stamps + // `disabled: true` and calls `write()` in the same synchronous step; once + // the self-dispose has settled, a regression has already written. Polling + // would not help — the assertion is an ABSENCE, and no amount of waiting + // proves one — so the wait only has to clear settlement. + await new Promise(resolve => setTimeout(resolve, 50)) + + // Inherited, `EntryTree.write()` persists the dying tree — stamping + // `disabled: true` onto the row and, in the shipped case, truncating the + // composition every session shares. + expect(await readFile(path, 'utf8')).toBe(composition) + }) +}) + +describe('attributing a service to a subtree', () => { + it('attributes nothing to a subtree that is already torn down', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-torn'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const [mount] = livePresetMounts().filter(entry => entry.presetId === 'standard') + expect(mount).toBeDefined() + + await handle.dispose() + + // A disposed subtree owns nothing, so it can never be blamed for a service + // some other subtree published under the same name afterwards. + expect(leakedServices(ctx, mount!.fiber)).toEqual([]) + }) +}) + +describe('replacing a composition', () => { + it('swaps the agent\'s tools without touching another session', async () => { + const keeper = await agentOn(ctx, 'sess-keeper', 'standard') + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-swap'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + expect(toolNames(ctx, keeper)).toEqual(['alpha']) + expect(toolNames(ctx)).toEqual([]) + }) + + it('leaves the agent on its previous composition when the new one is unknown', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-unknown'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'nope')) + .rejects.toThrow(/not found/) + + // Resolution happens before any teardown, so an unknown id is a no-op. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('restores the previous composition when the new one fails to mount', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-restore'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // The swap is unmount-then-mount, so a failure must put the old one back + // rather than leave the agent with no tools at all. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('composes an agent that had nothing installed', async () => { + // An agent created without a preset has no binding to re-link, so the + // switch is its first bind — exactly a mount — and once bound only the + // roster's kept binding can move it again. + const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') }) + + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + }) + + it('refuses a bare agent\'s broken composition without restoring anything', async () => { + const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // Nothing was installed, so there is nothing to put back. + expect(toolNames(ctx, handle.agent)).toEqual([]) + }) + + it('keeps the agent on its standing composition when a switch fails, even with the source deleted', async () => { + // A preset root this test owns, so removing the composition mid-flight + // cannot disturb the shipped fixtures. + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-')) + const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', '- id: nope\n name: ./does-not-exist.js\n']] + for (const [id, body] of seeded) { + await mkdir(join(root, id)) + await writeFile(join(root, id, COMPOSITION_FILE), body) + } + const scoped = new Context() + scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' + await scoped.plugin(Loader) + scoped.loader.builtins.include = Include + await scoped.plugin(LlmService) + await scoped.plugin(SessionStore) + await scoped.plugin(SystemPrompt, { persona: '' }) + await scoped.plugin(ToolRegistry) + await scoped.plugin(AgentRegistry) + await scoped.plugin(AgentLoop, { agents: [] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + const handle = await scoped.agents.create({ + sessionId: SessionId('sess-restore-gone'), + setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), + }) + + // The roster is a live directory: the composition the agent came from can + // be gone from DISK by the time a switch fails. The standing mount is not + // the file — it outlives deletion, so there is nothing to "restore". + await rm(join(root, 'first'), { recursive: true }) + + await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // The failed switch left the agent EXACTLY as it was: the new standing + // mount is ensured before the parent link moves, so a rejection never + // strips the old composition. + expect(toolNames(scoped, handle.agent)).toEqual(['only']) + }) + + it('refuses an unscoped context', async () => { + await expect(ctx.agentPresets.recompose(ctx, 'minimal')) + .rejects.toThrow(/unscoped context/) + }) +}) + +describe('editing a composition file', () => { + /** One-row composition whose single tool is named `tool`. */ + const rowFor = (tool: string): string => + `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: ${tool}\n` + + /** + * A context over a temp root holding one editable preset. The id is + * per-test because `livePresetMounts()` is a process-global registry. + */ + async function editable(id: string): Promise<{ scoped: Context; path: string }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-')) + await mkdir(join(root, id)) + const path = join(root, id, COMPOSITION_FILE) + await writeFile(path, rowFor('before')) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] }) + return { scoped, path } + } + + it('starts a new generation for later sessions while joined ones keep theirs', async () => { + const { scoped, path } = await editable('edited') + const first = await agentOn(scoped, 'sess-gen-first', 'edited') + expect(toolNames(scoped, first)).toEqual(['before']) + + // Files are the only composition editor now (authoring is copy/delete), + // so the standing mount notices the file's stamp changing on its own. + await writeFile(path, rowFor('afterwards')) + + const second = await agentOn(scoped, 'sess-gen-second', 'edited') + expect(toolNames(scoped, second)).toEqual(['afterwards']) + // The joined session keeps the generation it runs on. + expect(toolNames(scoped, first)).toEqual(['before']) + }) + + it('gives two sessions racing the refreshed file one shared new generation', async () => { + const { scoped, path } = await editable('raced') + await agentOn(scoped, 'sess-race-seed', 'raced') + + await writeFile(path, rowFor('afterwards')) + + // Whichever racer swaps the pointer first, the other must join it rather + // than fork a third generation off the same edit. + const [left, right] = await Promise.all([ + agentOn(scoped, 'sess-race-left', 'raced'), + agentOn(scoped, 'sess-race-right', 'raced'), + ]) + expect(toolNames(scoped, left)).toEqual(['afterwards']) + expect(toolNames(scoped, right)).toEqual(['afterwards']) + expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2) + }) + + it('hands a host reader the standing key without starting an agent', async () => { + const { scoped } = await editable('cold-read') + + const key = await scoped.agentPresets.standingKeyFor('cold-read') + + // The mount exists for the reader; no agent, session, or turn started. + expect(key).toEqual({ agentPreset: 'cold-read' }) + expect(livePresetMounts().filter(mount => mount.presetId === 'cold-read')).toHaveLength(1) + expect(scoped.agents.get(SessionId('cold-read'))).toBeUndefined() + // A second reader resolves the same generation, not a new mount. + expect(await scoped.agentPresets.standingKeyFor('cold-read')).toBe(key) + }) + + it('refuses to mount a generation it cannot stamp', async () => { + const { scoped, path } = await editable('unstampable') + await rm(path) + + // Discovery would refuse the preset too; a caller that resolved just + // before the deletion must get a mount failure, not an unstamped + // generation that no later edit could ever refresh. + const racer = scoped.agentPresets as unknown as { + ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown> + } + await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path })) + .rejects.toThrow(PresetMountError) + expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0) + }) + + it('keeps serving the mounted generation when the file cannot be statted', async () => { + const { scoped, path } = await editable('stale') + await agentOn(scoped, 'sess-stale-served', 'stale') + expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1) + + await rm(path) + + // Discovery refuses a preset whose composition cannot be statted, so the + // public route cannot reach this state — but a caller that resolved just + // before the deletion still can, and it must be served the standing + // generation rather than failed over a stat. + const racer = scoped.agentPresets as unknown as { + ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown> + } + await racer.ensureStanding({ id: 'stale', trust: 'user', path }) + + expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1) + }) +}) diff --git a/packages/preset/agent-presets/tests/session.spec.ts b/packages/preset/agent-presets/tests/session.spec.ts new file mode 100644 index 0000000000..d87c4d1937 --- /dev/null +++ b/packages/preset/agent-presets/tests/session.spec.ts @@ -0,0 +1,62 @@ +/** + * Which preset a session ran is a question about its LOG, not its header: the + * header records the creation-time choice, and a switch made during the blank + * window is an event. Every reconstruction — the list row, the header label, + * resume, fork — goes through this resolver, so a resolver that read the header + * alone would rebuild a switched session under a composition its own history + * contradicts. + */ + +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { resolveSessionPreset } from '../src/session.ts' + +/** A header carrying the creation-time preset, if any. */ +function header(agentPreset?: string): SessionHeader { + return { + version: 0, + id: SessionId('s'), + createdAt: 1, + delegationDepth: 0, + ...agentPreset === undefined ? {} : { agentPreset }, + } +} + +/** One logged selection, as `agentPreset.select` appends it. */ +function selected(agentPreset: string, seq: number): SessionEvent { + return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } } +} + +describe('resolving which preset a session ran', () => { + it('reads the creation-time value when nothing was switched', () => { + expect(resolveSessionPreset({ header: header('standard'), events: [] })).toBe('standard') + }) + + it('prefers a logged switch over the header', () => { + // The switch's effect outlives the blank window it was made in: the turns + // that follow run under the newer composition. + expect(resolveSessionPreset({ header: header('standard'), events: [selected('minimal', 0)] })) + .toBe('minimal') + }) + + it('takes the last switch when a session was moved twice', () => { + expect(resolveSessionPreset({ + header: header('standard'), + events: [selected('minimal', 0), selected('cordis', 1)], + })).toBe('cordis') + }) + + it('finds a switch behind later events', () => { + const later = { type: 'turn/end', seq: 2, time: 2, data: { turn: 1 } } as SessionEvent + + expect(resolveSessionPreset({ header: header(), events: [selected('minimal', 0), later] })) + .toBe('minimal') + }) + + it('reports none when the deployment composes no presets', () => { + // A valid deployment: every session shares the host composition, and no + // surface should invent a preset name for it. + expect(resolveSessionPreset({ header: header(), events: [] })).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts new file mode 100644 index 0000000000..081ffceba4 --- /dev/null +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -0,0 +1,163 @@ +/** + * The default preset is a user setting. `config.default` is the deployment's + * engineering default; the settings document overrides it and is hot-reloaded, + * so a person can change which preset new sessions get without a restart. + */ + +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { describe, expect, it } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }] +const NS = settingsNamespace(SETTINGS_NAMESPACE) + +/** + * A composition with a real file-backed settings provider. `settingsFiber` is + * the provider's own handle, so a test can take it away the way a reload does. + */ +async function harness( + extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [], +): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-')) + const settingsFile = join(home, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) + await settingsFiber + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) + return { ctx, settingsFile, settingsFiber } +} + +const toolNames = (ctx: Context, agent?: unknown): string[] => + ctx.tools.schemas(agent as never).map(schema => schema.name).sort() + +describe('the default preset as a user setting', () => { + it('falls back to the composition default while the user set none', async () => { + const { ctx } = await harness() + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('takes the user default over the composition default', async () => { + const { ctx } = await harness() + + await ctx.settings.update(NS, { default: 'minimal' }) + + expect(ctx.agentPresets.defaultId).toBe('minimal') + }) + + it('composes a new session from the user default', async () => { + const { ctx } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + + const handle = await ctx.agents.create({ + sessionId: SessionId('settings-default'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), + }) + try { + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + } finally { + await handle.dispose() + } + }) + + it('leaves a running session on the preset it was composed from', async () => { + const { ctx } = await harness() + const running = await ctx.agents.create({ + sessionId: SessionId('settings-running'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), + }) + try { + expect(toolNames(ctx, running.agent)).toEqual(['alpha']) + + // Changing the default mid-flight must not reach an agent that already + // composed: its history was produced under `standard`'s tools. + await ctx.settings.update(NS, { default: 'minimal' }) + + expect(ctx.agentPresets.defaultId).toBe('minimal') + expect(toolNames(ctx, running.agent)).toEqual(['alpha']) + } finally { + await running.dispose() + } + }) + + it('re-inherits the composition default when the user setting is cleared', async () => { + const { ctx } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + + await ctx.settings.replace(NS, {}) + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('clears a user default it has just deleted', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-')) + await mkdir(join(root, 'mine')) + await writeFile( + join(root, 'mine', COMPOSITION_FILE), + `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`, + ) + const { ctx } = await harness([{ path: root, trust: 'user' as const }]) + await ctx.settings.update(NS, { default: 'mine' }) + expect(ctx.agentPresets.defaultId).toBe('mine') + + await ctx.agentPresets.remove('mine') + + // Nothing will ever supply that id again, so leaving the setting pointed at + // it would fail every session created without an explicit pick. Clearing it + // exposes the deployment's own default underneath. + expect(ctx.agentPresets.defaultId).toBe('standard') + expect((await ctx.agentPresets.resolve()).id).toBe('standard') + }) + + it('reports an unknown user default only when a session tries to use it', async () => { + const { ctx } = await harness() + + // Storing it succeeds — the roster is a live directory, so a name that is + // absent now may exist by the time a session asks for it. + await ctx.settings.update(NS, { default: 'no-such-preset' }) + + await expect(ctx.agentPresets.resolve()) + .rejects.toThrow(/preset "no-such-preset" not found/) + }) +}) + +describe('a settings provider that goes away', () => { + it('falls back to the composition default when the provider unloads', async () => { + const { ctx, settingsFiber } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + + // Unloading the provider takes the user layer with it; the roster keeps + // working on its composition default rather than holding a stale override. + await settingsFiber.dispose() + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json new file mode 100644 index 0000000000..47d5577207 --- /dev/null +++ b/packages/preset/agent-presets/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../core/session" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../util/atomic-write" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml new file mode 100644 index 0000000000..c4573b49f8 --- /dev/null +++ b/packages/preset/persona/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/preset/persona/README.md +README.md: 789776b32d907f7d217accccbca5508f88de0ed1 +README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md new file mode 100644 index 0000000000..789776b32d --- /dev/null +++ b/packages/preset/persona/README.md @@ -0,0 +1,39 @@ +# dsh-persona + +English | [中文](README.zh.md) + +The agent persona as a composable row. One config field, one prompt section. + +[`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row. + +## Scope-only + +Mounting this row outside an agent scope collides with the registry's own `deployment:persona` registration and fails loud. That is not a limitation to work around: the deployment persona already has an owner, and the whole point of this row is to shadow it for one agent. Mount it inside a preset composition, where the preset mount supplies the agent scope. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `text` | required | Persona prose rendered as the `deployment:persona` section | + +`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. + +## Model Experience + +### The persona section + +#### What the model sees + +The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. + +#### Token effect + +Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. + +#### KV Cache effect + +Prefix-stable for the life of an agent — the row mounts once, before the agent is published and therefore before its first request, and its text never changes while the agent runs. Two agents on different presets establish different prefixes from this section onward; neither can invalidate the other's reuse. + +## Known Limitations and Deferred Work + +- **No global mount** — the prompt registry owns the unscoped persona slot, so this row is usable only from a scoped composition. A deployment-wide persona change belongs in the `system-prompt` row's own config. diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md new file mode 100644 index 0000000000..4e28d75bbd --- /dev/null +++ b/packages/preset/persona/README.zh.md @@ -0,0 +1,39 @@ +# dsh-persona + +[English](README.md) | 中文 + +把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。 + +[`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 + +## 仅限 scope 内使用 + +在 agent scope 之外挂载本行,会与注册表自身的 `deployment:persona` 注册相撞并明确报错。这不是需要绕开的限制:部署级人设已经有归属,而本行存在的意义正是为某一个 agent 遮蔽它。请把它挂在 preset 组装内部,由 preset 的挂载过程提供 agent scope。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 | + +`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。 + +## Model Experience + +### 人设段落 + +#### What the model sees + +位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。 + +#### Token effect + +对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。 + +#### KV Cache effect + +在一个 agent 的整个生命周期内保持前缀稳定——本行只挂载一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间文本不再改变。两个使用不同 preset 的 agent 从该段落起建立各自不同的前缀,谁都无法让对方失去缓存复用。 + +## Known Limitations and Deferred Work + +- **不支持全局挂载** —— 提示词注册表拥有未加 scope 的人设槽位,因此本行只能从带 scope 的组装中使用。要改变部署级人设,应在 `system-prompt` 行自身的配置中修改。 diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json new file mode 100644 index 0000000000..5ec7678d16 --- /dev/null +++ b/packages/preset/persona/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-persona", + "description": "Composition-authored deployment persona section 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" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts new file mode 100644 index 0000000000..ec56bcc780 --- /dev/null +++ b/packages/preset/persona/src/index.ts @@ -0,0 +1,60 @@ +/** + * A per-agent persona as a composable row. + * + * `dsh-system-prompt` owns the global persona as its own config, and registers + * that section unconditionally — so this row is **scope-only**. Mounted inside + * an agent preset it shadows the deployment persona for that one session, + * exactly like the per-child persona `dsh-subagent` installs; mounted globally + * it collides with the registry's own registration and fails loud. + * + * That constraint is the reason the row exists. An agent preset cannot mount + * the prompt registry itself, so without a row of its own a preset could + * change an agent's tools but never its identity. + * @module @deepseek-ai/dsh-persona + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-system-prompt' + +// Imported rather than restated: the registry declares the slot this row +// replaces, and two hardcoded copies would drift into a preset whose persona +// silently lands beside the deployment's instead of shadowing it. +import { PERSONA_ORDER, PERSONA_SECTION } from '@deepseek-ai/dsh-system-prompt' + +export { PERSONA_ORDER, PERSONA_SECTION } + +/** Cordis plugin name. */ +export const name = 'persona' + +/** The prompt registry this row contributes to. */ +export const inject = ['systemPrompt'] + +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} + +/** Runtime schema for the persona row. */ +export const Config: z<Config> = z.object({ + text: z.string().required(), +}) + +/** + * Register the persona section for the mounting context's scope. + * @param ctx - an agent scope context; an unscoped context collides with the + * prompt registry's own persona registration and rejects. + * @param config - the persona text. + */ +export function apply(ctx: Context, config: Config): void { + ctx.effect(() => ctx.systemPrompt.section({ + name: PERSONA_SECTION, + order: PERSONA_ORDER, + text: config.text, + }), 'persona.section()') +} diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts new file mode 100644 index 0000000000..5f9068fe24 --- /dev/null +++ b/packages/preset/persona/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-persona`. + * @module @deepseek-ai/dsh-persona/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-persona' + +/** Cordis companion plugin name. */ +export const name = 'persona-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one + * prompt section and the prompt registry owns section identity, shadowing, and disposal. + */ +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/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts new file mode 100644 index 0000000000..bb7555df7c --- /dev/null +++ b/packages/preset/persona/tests/persona.spec.ts @@ -0,0 +1,88 @@ +import { Context } from 'cordis' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { describe, expect, it } from 'vitest' +import * as Persona from '@deepseek-ai/dsh-persona' +import { PERSONA_SECTION } from '@deepseek-ai/dsh-persona' + +async function harness(deploymentPersona: string): Promise<Context> { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: deploymentPersona }) + return ctx +} + +/** The rendered text of the persona slot as one scope sees it. */ +async function personaText(ctx: Context, scope?: ScopeKey): Promise<string | undefined> { + const assembly = await ctx.systemPrompt.assemble(scope === undefined ? {} : { scope }) + return assembly.sections.find(section => section.name === PERSONA_SECTION)?.text +} + +describe('the persona row', () => { + it('rejects an unscoped mount, which would collide with the registry default', async () => { + const ctx = await harness('deployment identity') + + await expect(ctx.plugin(Persona, { text: 'composition identity' })) + .rejects.toThrow(/"deployment:persona" is already registered/) + }) + + it('shadows the deployment default for one scope only', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + + await scope.ctx.plugin(Persona, { text: 'preset identity' }) + + expect(await personaText(ctx, key)).toBe('preset identity') + expect(await personaText(ctx)).toBe('deployment identity') + }) + + it('gives two scopes independent personas', async () => { + const ctx = await harness('') + const first: ScopeKey = { agent: 'a1' } + const second: ScopeKey = { agent: 'a2' } + + await createScope(ctx, first).ctx.plugin(Persona, { text: 'first identity' }) + await createScope(ctx, second).ctx.plugin(Persona, { text: 'second identity' }) + + expect(await personaText(ctx, first)).toBe('first identity') + expect(await personaText(ctx, second)).toBe('second identity') + }) + + it('shadows the deployment persona away entirely when its text is empty', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + + await createScope(ctx, key).ctx.plugin(Persona, { text: '' }) + + // The slot is still occupied, so the deployment persona is gone for this + // agent; an empty section is dropped when the prompt renders. + expect(await personaText(ctx, key)).toBe('') + expect(await personaText(ctx)).toBe('deployment identity') + }) + + it('restores the shadowed default when its fiber unloads', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + const fiber = await scope.ctx.plugin(Persona, { text: 'preset identity' }) + expect(await personaText(ctx, key)).toBe('preset identity') + + await fiber.dispose() + + expect(await personaText(ctx, key)).toBe('deployment identity') + }) + + it('interpolates prompt variables strictly, like any other section', async () => { + const ctx = await harness('') + const key: ScopeKey = { agent: 'a1' } + ctx.systemPrompt.variable('model', () => 'deepseek-v4-pro') + + await createScope(ctx, key).ctx.plugin(Persona, { text: 'You run on {{model}}.' }) + + // `assemble()` keeps section text uninterpolated; `renderPrompt()` is the + // stage that resolves `{{…}}` against the assembly's variables. + expect(await personaText(ctx, key)).toBe('You run on {{model}}.') + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))) + .toContain('You run on deepseek-v4-pro.') + }) +}) diff --git a/packages/preset/persona/tsconfig.json b/packages/preset/persona/tsconfig.json new file mode 100644 index 0000000000..178bd54dbb --- /dev/null +++ b/packages/preset/persona/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index d61eb58da5..dd3a956536 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -215,7 +215,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' }, }]) }) @@ -247,7 +247,7 @@ describe('LocalPtyBackend startup rollback', () => { }) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' }, }]) }) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 1c0de1660a..31ffaf7e3f 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -142,7 +142,7 @@ describe('pty-local real shell', () => { const created = await ctx.pty.spawn(agent, { type: 'shell' }) expect(sandbox.calls).toEqual([{ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) }, + policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root), sessionId: 'agent-workspace-write' }, }]) await fiber.dispose() expect(ctx.pty.listBackends()).toEqual([]) diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index eace2ef08c..226c241204 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sandbox-local", - "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed", + "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", "version": "0.0.1", "private": true, "type": "module", @@ -28,9 +28,11 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { + "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*", "schemastery": "^3.18.0" }, @@ -38,6 +40,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 1deefc7657..42a150b855 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,12 +1,29 @@ /** * Local sandbox backend. It selects the platform runner chain (Linux bwrap then - * Landlock; macOS Seatbelt), functionally probes competing candidates once, and - * reports each wrap's enforcement and stderr classification facts. Missing or unusable - * confinement fails closed rather than returning the original argv. + * Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes + * competing candidates once, and reports each wrap's enforcement and stderr + * classification facts. Missing or unusable confinement fails closed rather + * than returning the original argv. + * + * The windows-acl rung additionally owns the write grants: the write SID is + * the per-WORKSPACE identity derived from the canonical workspace path + * (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per + * session (session id + workspace — nothing stored). The + * workspace-root ACE materializes once per workspace per server lifetime + * and STANDS (the cross-session reuse cache — the exact-ACE skip makes + * every later provision O(1) instead of re-propagating the tree per + * session); the private-temp ACEs are revoked on dispose. The runner + * receives `--write-sid` (the derived identity; its presence marks the + * seam-managed contract) and stops managing DACLs itself. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, @@ -18,6 +35,8 @@ import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -70,6 +89,46 @@ function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean return probe.status === 0 } +/** + * Functional windows-acl probe: run the runner in read-only mode (zero grants, + * no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created + * the restricted token and spawned the child under it. The win32 chain is a + * sole candidate, so the product never probes; the probe exists for override + * chains and mirrors the other rungs' shape. + */ +function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean { + const program = runnerInvocation[0] + if (program === undefined) return false + const probe = spawnSync(program, [ + ...runnerInvocation.slice(1), + '--workspace', tmpdir(), '--temp', tmpdir(), '--mode', 'read-only', + '--', 'cmd', '/c', 'exit', '0', + ], { + timeout: timeoutMs, + stdio: 'ignore', + }) + return probe.status === 0 +} + +/** + * The session's private temp subdirectory: `<tmpdir>\dsh-<16 hex>`, derived + * from the session id and its workspace instead of stored. The same session + * and workspace always name the same directory — a resumed session + * re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's + * different session id names a fresh one. The name is predictable to anyone + * who knows the session id (the confined command sees it as + * `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and + * rejects reparse points: a pre-placed entry fails the first confined run + * loudly, and cannot redirect the grant onto a foreign object. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. + * @returns the session's private temp subdirectory path. + */ +export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string { + const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex') + return join(tmpdir(), `dsh-${digest.slice(0, 16)}`) +} + /** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -86,10 +145,18 @@ export interface SandboxInternals { landlockLauncher?: string /** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */ seatbeltExec?: string + /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */ + windowsAclRunnerArgs?: string[] + /** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */ + windowsAclRunnerEntry?: string + /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ + probeWindowsAcl?: () => boolean + /** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */ + rmTempDir?: (path: string) => void } /** The chain's verdict: which runner confines, and how completely it enforces. */ -type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement } +type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement } /** * The runner chain per platform — selection is BY PLATFORM first, probes @@ -103,11 +170,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = { linux: ['bwrap', 'landlock'], darwin: ['seatbelt'], - // Reserved slot, deliberately empty: Windows support fills it with a confinement runner - // (AppContainer / restricted-token family, shipped from its own repository on the - // landlock-run template) plus a SelectedRunner['runner'] union member — the switches' - // assertNever guards then walk the implementer to every site. - win32: [], + // The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl): + // a sole candidate, selected without a probe — its execution-time refusal + // fails closed through its stderr signature (windows-acl-run:) and exit 127. + win32: ['windows-acl'], } /** @@ -123,6 +189,13 @@ const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> = bwrap: 'full', landlock: 'full', seatbelt: 'full', + // 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists + // close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are + // absent from both — pinned by the runner's Public-probe and CIM-denial + // regressions). FAT-class (non-ACL) targets are declared unsupported + // (warn-only) in the backend README — outside the promise, not an + // exception to it. + 'windows-acl': 'full', } /** @@ -145,15 +218,26 @@ const DENIAL_SIGNATURES = { bwrap: ['read-only file system'], landlock: ['permission denied'], seatbelt: ['operation not permitted'], + // pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied."; + // node EACCES: "permission denied". + 'windows-acl': ['access is denied', 'access to the path', 'permission denied'], runnerCommand: ['read-only file system', 'permission denied'], } as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]> +/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */ +const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127 + /** * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. - * Keep the Landlock tuple aligned with the assembled snapshot fixture at + * The windows-acl runner prints `windows-acl-run: <detail>` on every + * runner-side failure and exits 127 — the rule is exit-gated on that status + * so a confined command that merely PRINTS the signature (or a runner + * cleanup failure reported on a non-zero child exit) is never misclassified + * as "the command did not run". Keep the Landlock tuple aligned with the + * assembled snapshot fixture at * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ const RUNNER_FAILURE_RULES = { @@ -164,12 +248,15 @@ const RUNNER_FAILURE_RULES = { informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], + 'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }], } as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]> /** - * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless - * apart from the cached chain verdict — it spawns nothing but the one-time - * probes, so there is no disposal work beyond cordis' own. + * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the + * chain verdict and, on the windows-acl rung, the write grants + * ({@link AclWriteGrant}: the standing workspace-root grant per workspace + * and the revocable private-temp grant per session, the latter revoked on + * provider dispose); the one-time probes spawn nothing else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -187,6 +274,16 @@ export class LocalSandboxProvider extends SandboxProvider { private readonly probeTimeoutMs: number /** Cached chain verdict; undefined until the first confined wrap needs it. */ private selectedRunner: SelectedRunner | 'unavailable' | undefined + /** + * Server-lifetime write grants (windows-acl rung): the STANDING + * workspace-root grant per workspace (its ACE is the cross-session reuse + * cache and outlives the provider — never revoked) and the REVOCABLE + * private-temp grant per session (revoked on provider dispose). + */ + private readonly workspaceGrants = new Map<string, AclWriteGrant>() + private readonly tempGrants = new Map<string, AclWriteGrant>() + /** Session id → the private temp directory this provider created (removed on dispose). */ + private readonly tempDirs = new Map<string, string>() constructor(ctx: Context, config: Config) { super(ctx) @@ -208,6 +305,13 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) + // The temp grants are revoked with the provider: a clean server + // shutdown leaves no temp ACEs behind (workspace ACEs stand by design — + // the reuse cache; an unclean shutdown leaves them for the next + // provision's exact-ACE skip). + ctx.effect(() => () => { + this.revokeAclGrants() + }) } /** @@ -246,10 +350,154 @@ export class LocalSandboxProvider extends SandboxProvider { case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)] case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)] case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)] + case 'windows-acl': return this.windowsAclRunnerArgv(policy) default: return assertNever(runner) } } + /** + * The windows-acl runner argv for one policy. With a calling session (the + * policy's `sessionId`), the write grants are materialized once per server + * lifetime — the standing workspace-root grant per workspace and the + * revocable private-temp grant per session — and the runner receives + * `--write-sid` (the workspace-derived identity; its presence marks the + * seam-managed DACL contract) plus, under workspace-write, the session's + * PRIVATE temp subdirectory (derived from session id + workspace) — it + * grants nothing and revokes nothing. Agentless calls pass the ambient + * temp root and no `--write-sid`: the runner self-manages its DACLs. + * @param policy - the resolved per-call policy. + * @returns the runner invocation. + */ + private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { + const sessionId = policy.sessionId + if (sessionId === undefined) { + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + '--temp', tmpdir(), + '--mode', policy.mode, + ] + } + this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode) + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + // Workspace-write sessions confine their temp writes to the PRIVATE + // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only + // runs pass the ambient temp root — the runner validates it exists + // but grants nothing. The derived write SID is the per-workspace + // identity; the flag's presence marks the seam-managed DACL contract. + '--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(), + '--mode', policy.mode, + '--write-sid', workspaceWriteSid(policy.workspaceRoot), + ] + } + + /** + * Materialize the session's ACEs once per server lifetime: lazily at its + * first confined execution, reused for every later call (the map hits are + * the whole call). The write SID is the per-workspace identity derived + * from the workspace. Workspace-write grants the workspace root STANDING + * (the ACE outlives every session — the reuse cache) and the session's + * private temp subdirectory REVOCABLY — the directory is derived from + * session id + workspace, created here EXCLUSIVELY (a pre-existing entry + * or a reparse point fails the first confined run loudly, so the grant + * never lands on a foreign object); read-only materializes NOTHING — its + * token alone restricts every write, and the standing grant from an + * earlier workspace-write period is KEPT through a downgrade (never + * revoked): the read-only restricted token carries no write SID (the + * read-only list), so the ACE is inert there, while the map hit keeps the + * re-upgrade free of re-propagation. Fail-closed: a half-materialized + * temp grant is revoked before the error propagates. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. + * @param mode - the policy mode (grants exist only under workspace-write). + */ + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void { + if (mode === 'read-only') return + const writeSid = workspaceWriteSid(workspaceRoot) + const tempDir = sessionTempDir(sessionId, workspaceRoot) + if (!this.workspaceGrants.has(workspaceRoot)) { + const grant = AclWriteGrant.create(writeSid) + try { + grant.add(workspaceRoot, true) + } catch (error) { + // Free the SID; a standing ACE (if the apply succeeded before a + // post-apply throw) is the intended end state, not an error + // artifact — nothing to revoke. + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed') + } + throw error + } + this.workspaceGrants.set(workspaceRoot, grant) + } + if (this.tempGrants.has(sessionId)) return + const grant = AclWriteGrant.create(writeSid) + // The directory is removed again in the catch only when THIS confine + // created it — a pre-existing entry (EEXIST) is a foreign object and is + // never deleted. + let created = false + try { + // Exclusive creation (no `recursive`): a pre-existing entry OR a + // reparse point both fail EEXIST — the grant never lands on a foreign + // object. + mkdirSync(tempDir) + created = true + grant.add(tempDir) + } catch (error) { + if (created) rmSync(tempDir, { recursive: true, force: true }) + // Revoke whatever stands and free the SID — never leave a half-grant + // behind a failed confine (the runner never runs). + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') + } + throw error + } + this.tempGrants.set(sessionId, grant) + this.tempDirs.set(sessionId, tempDir) + } + + /** + * Dispose every write grant (provider dispose): the revocable temp ACEs + * are revoked, the private temp directories this provider created are + * removed, and every SID allocation is freed; the standing workspace ACEs + * stay (the reuse cache). Cleanup failures are reported, not thrown: + * cordis teardown must not be aborted by grant cleanup. A crash skips all + * of it — the next resume then fails loudly at the exclusive creation and + * OS temp hygiene (or manual removal) recovers. + */ + private revokeAclGrants(): void { + if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return + const failures: unknown[] = [] + for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) { + try { + grant.dispose() + } catch (error) { + failures.push(error) + } + } + const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) }) + for (const dir of this.tempDirs.values()) { + try { + rmTempDir(dir) + } catch (error) { + failures.push(error) + } + } + this.workspaceGrants.clear() + this.tempGrants.clear() + this.tempDirs.clear() + if (failures.length > 0) { + this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) + for (const error of failures) this.ctx.logger.warn(error) + } + } + /** * Resolve which runner confines commands, once, for the provider's * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole @@ -296,6 +544,11 @@ export class LocalSandboxProvider extends SandboxProvider { const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs)) return probe(this.seatbeltExec()) ? 'full' : 'unusable' } + case 'windows-acl': { + const probe = this.internals.probeWindowsAcl + ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs)) + return probe() ? 'full' : 'unusable' + } default: return assertNever(runner) } } @@ -309,6 +562,21 @@ export class LocalSandboxProvider extends SandboxProvider { private seatbeltExec(): string { return this.internals.seatbeltExec ?? 'sandbox-exec' } + + /** + * The windows-acl runner argv prefix: the built lib/runner.js entry when + * present (production), else the package source through tsx (development). + * The prefix stays `[node, runner, ...]` — a future native-exe runner keeps + * the same argv contract and only swaps these entries. + */ + private windowsAclRunnerInvocation(): string[] { + const override = this.internals.windowsAclRunnerArgs + if (override !== undefined) return override + const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) + if (existsSync(builtEntry)) return [process.execPath, builtEntry] + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts')) + return [process.execPath, '--import', 'tsx/esm', sourceEntry] + } } export default LocalSandboxProvider diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts new file mode 100644 index 0000000000..ca2410c317 --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -0,0 +1,404 @@ +/** + * windows-acl write grants: the SERVER-LIFETIME ACE materialization + * (standing workspace grant per workspace, revocable private-temp grant per + * session) plus the derived private-temp identity, through the REAL + * LocalSandboxProvider.confine(). Win32 surface mocked at the package + * boundary (the workspace-derived SID mocked to a constant); the real-FFI + * grant behavior lives in sandbox-windows-acl's win32 tests. + */ + +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SessionId } from '@deepseek-ai/dsh-session' +import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local' + +/** Cross-file state shared with the vi.mock factory (hoisting contract). */ +const mockState = vi.hoisted(() => ({ + grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, + addFailure: undefined as Error | undefined, + /** Restricts {@link addFailure} to this path (undefined = every add throws). */ + addFailurePath: undefined as string | undefined, + disposeFailure: undefined as Error | undefined, +})) + +vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { + class MockAclWriteGrant { + readonly writeSid: string + readonly added: Array<{ path: string; standing: boolean }> = [] + disposed = false + constructor(writeSid: string) { + this.writeSid = writeSid + mockState.grants.push(this) + } + static create(writeSid: string): MockAclWriteGrant { + return new MockAclWriteGrant(writeSid) + } + add(path: string, standing = false): void { + if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) { + throw mockState.addFailure + } + this.added.push({ path, standing }) + } + dispose(): void { + if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure + this.disposed = true + } + } + return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' } +}) + +/** The workspace-derived write SID the mock pins for every workspace. */ +const DERIVED_SID = 'S-1-4-42-42' + +async function setup() { + const ctx = new Context() + const fiber = await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } + return { ctx, sandbox, fiber } +} + +/** A workspace root the policy carries. */ +function workspaceRoot(): string { + return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-')) +} + +describe('windows-acl write grants (LocalSandboxProvider)', () => { + const scratch: string[] = [] + + beforeEach(() => { + mockState.grants = [] + mockState.addFailure = undefined + mockState.addFailurePath = undefined + mockState.disposeFailure = undefined + }) + + const cleanup = () => { + for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) + } + + it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => { + try { + const { sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const tempDir = sessionTempDir(SessionId('sess-1'), ws) + scratch.push(tempDir) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } + + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tempDir, + '--mode', 'workspace-write', + '--write-sid', DERIVED_SID, + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked + disposed: false, + }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: tempDir, standing: false }], + disposed: false, + }) + expect(existsSync(tempDir)).toBe(true) // created exclusively + + // Reuse: the second confine is the map hits. + sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(mockState.grants).toHaveLength(2) + + await fiber.dispose() + // dispose() runs on BOTH grants: the standing workspace ACE is left in + // place (the mock marks it disposed only as instance teardown). + expect(mockState.grants[0]!.disposed).toBe(true) + expect(mockState.grants[1]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const tempDir = sessionTempDir(SessionId('sess-switch'), ws) + scratch.push(tempDir) + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + + // read-only first: nothing materialized, ambient temp. + const confinedRo = sandbox.confine(['true'], readOnly) + expect(confinedRo.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing + '--mode', 'read-only', + '--write-sid', DERIVED_SID, + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(0) + expect(existsSync(tempDir)).toBe(false) + + // Upgrade: first workspace-write materializes with the derived SID. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tempDir, + '--mode', 'workspace-write', + '--write-sid', DERIVED_SID, + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: tempDir, standing: false }], + disposed: false, + }) + expect(existsSync(tempDir)).toBe(true) + + // Reuse: map hits. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(2) + + // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) + } finally { + cleanup() + } + }) + + it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => { + try { + const ws = workspaceRoot() + scratch.push(ws) + const first = await setup() + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } + const firstConfined = first.sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(2) + + // Clean restart: dispose revokes the temp ACE and removes the private + // temp directory, so the fresh provider's exclusive creation succeeds. + await first.fiber.dispose() + mockState.grants = [] + const second = await setup() + const secondConfined = second.sandbox.confine(['true'], policy) + expect(secondConfined.argv).toEqual(firstConfined.argv) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }], + }) + await second.fiber.dispose() + } finally { + cleanup() + } + }) + + it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') } + const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } + + sandbox.confine(['true'], parentPolicy) + const parentTemp = sessionTempDir(SessionId('parent'), ws) + scratch.push(parentTemp) + sandbox.confine(['true'], childPolicy) + const childTemp = sessionTempDir(SessionId('child'), ws) + scratch.push(childTemp) + + // Fresh temp identity, NOT the parent's (the workspace SID is shared by + // derivation — the workspace is the same, so the standing grant is the + // map hit and only the child's temp grant joins). + expect(childTemp).not.toBe(parentTemp) + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) + } finally { + cleanup() + } + }) + + it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. + const preexisting = sessionTempDir(SessionId('preexisting'), ws) + mkdirSync(preexisting) + scratch.push(preexisting) + const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } + expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) + // The standing workspace grant is the intended end state and stays; the + // failed temp grant self-disposes. + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked + + // Reparse point: same EEXIST (exclusive mkdir never follows links). + const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) + scratch.push(target) + const linkPath = sessionTempDir(SessionId('reparse'), ws) + symlinkSync(target, linkPath) + scratch.push(linkPath) + const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } + expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) + // Same workspace as the preexisting case: the standing workspace grant + // is the map hit (not recreated) — only the failed temp grant joins. + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[2]!.disposed).toBe(true) + + // Temp-side cleanup failure: the standing workspace grant stays (map + // hit), the exclusive mkdir fails, AND the temp grant's dispose also + // fails — the temp cleanup AggregateError propagates. + mockState.grants = [] + mockState.disposeFailure = new Error('temp cleanup exploded') + const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws) + mkdirSync(dupTemp) + scratch.push(dupTemp) + const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } + expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) + expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) + } finally { + cleanup() + } + }) + + it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws)) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } + + // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. + mockState.addFailure = new Error('grant exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) + + // add() AND dispose() both throw: AggregateError. + mockState.grants = [] + mockState.addFailure = new Error('grant exploded again') + mockState.disposeFailure = new Error('cleanup exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError) + } finally { + cleanup() + } + }) + + it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') } + + // The workspace grant succeeds; only the TEMP grant's add throws (the + // path-targeted failure keeps the workspace branch intact). + mockState.addFailurePath = tempDir + mockState.addFailure = new Error('temp add exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded') + expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays + expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes + } finally { + cleanup() + } + }) + + it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => { + try { + const { sandbox, fiber } = await setup() + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(0) + await fiber.dispose() + } finally { + cleanup() + } + }) + + it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + scratch.push(sessionTempDir(SessionId('sess-dispose'), ws)) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(2) + + mockState.disposeFailure = new Error('revoke exploded') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() + // BOTH grants (standing workspace + revocable temp) fail their dispose. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) + } finally { + cleanup() + } + }) + + it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws)) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(2) + + sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') } + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() + // Both grants dispose cleanly; only the directory removal fails. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' })) + } finally { + cleanup() + } + }) + + it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => { + const base = sessionTempDir(SessionId('sess-a'), '/ws/a') + expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/) + expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base) + expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session + expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace + // The separator prevents id/workspace collisions from merging inputs. + expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc')) + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 93f725fe48..2edde27ca7 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -209,13 +209,10 @@ describe('the platform chains', () => { expect(probeSeatbelt).not.toHaveBeenCalled() }) - it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => { - // The slot exists so Windows support is an additive fill-in (chain entry - // + runner union member), never a redesign — and reserving it must not - // weaken the fail-closed end in the meantime. - const { sandbox } = await setup({}, { platform: 'win32' }) - expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) - }) + // The win32 chain's argv contract, denial dialect, and runner-failure rules + // live in @deepseek-ai/dsh-sandbox-windows-acl/tests/provider-chain.spec.ts + // (platform-independent assertions that run in every CI lane, including + // Windows where this package's POSIX-only suites are excluded). it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => { const probeBwrap = vi.fn(() => true) @@ -368,3 +365,63 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) }) }) + +describe('the windows-acl probe (runner invocation contract)', () => { + // The product chain reaches windows-acl only unprobed (win32's sole + // candidate), so the probe case and the runner-entry resolution are pinned + // through the chain seam, mirroring the seatbelt default-probe contract. + it('selects the rung when the injected probe passes, speaking the ACL dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl, + probeBwrap: () => false, + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + }) + const confined = sandbox.confine(['true'], RO) + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) + }) + + it('reads a failing probe as unusable and walks to the next rung', async () => { + const probeWindowsAcl = vi.fn(() => false) + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeWindowsAcl, probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + }) + + it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { + // The default probe spawns the exact runner argv confine would use — the + // runner source through tsx on a lib-less checkout. The windows-acl + // runner cannot init off win32, so the probe reads unusable and the walk + // falls through to the injected bwrap verdict on every host. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }, 30_000) + + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { + // windowsAclRunnerInvocation always yields [node, ...] in product; an + // override returning [] exercises the default probe's empty-argv guard. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true, windowsAclRunnerArgs: [] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }) + + it('prefers the built lib/runner.js entry when the resolved file exists', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-')) + const builtEntry = join(dir, 'runner.js') + writeFileSync(builtEntry, '') + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: builtEntry, + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 2)).toEqual([process.execPath, builtEntry]) + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 5f4fa5a3bb..a796f3e48a 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -28,6 +28,10 @@ const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${proces /** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-local', + // sandbox-local's win32 chain rung is a runtime dependency: a packed + // consumer resolves it like any other @deepseek-ai peer (koffi arrives + // from the registry). + 'packages/sandbox/sandbox-windows-acl', 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index 7a41ffc5fd..878ea379ba 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../sandbox" }, + { + "path": "../sandbox-windows-acl" + }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 4c87cbc8b3..3a3aac1d70 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -137,6 +137,7 @@ export class SandboxPolicyService extends Service { return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + ...session === undefined ? {} : { sessionId: session.id }, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index c535847f58..11552f2a3a 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -69,10 +69,12 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/projects/first'), + sessionId: 'sess-first', }) expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ mode: 'read-only', workspaceRoot: resolve('/projects/second'), + sessionId: 'sess-second', }) expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') @@ -98,6 +100,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ mode: 'workspace-write', workspaceRoot: realpathSync.native(physical), + sessionId: 'sess-symlink-parent', }) } finally { rmSync(root, { recursive: true, force: true }) @@ -111,6 +114,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ mode: 'danger-full-access', workspaceRoot: resolve('/projects/approved'), + sessionId: 'sess-approved', }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml new file mode 100644 index 0000000000..c53e5cf6fc --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/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/sandbox/sandbox-windows-acl/README.md +README.md: b13160f7490878143c719ca617936b74ffd298af +README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md new file mode 100644 index 0000000000..b13160f749 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -0,0 +1,91 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +English | [中文](README.zh.md) + +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. + +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). + +Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). + +## Usage + +```ts +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const workspaceRoot = process.cwd() + +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. workspace-write +// REQUIRES the workspace's write SID — the per-workspace identity. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +``` + +A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. + +## The confinement runner + +The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: + +```sh +node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...> +``` + +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. + +**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). + +Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): +- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. +- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). + +Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). + +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. + +## Header verification + +All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts): + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory. + +## Verified boundaries (inherent to restricted tokens, not this port) + +- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. +- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls <dir> /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. +- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`<temp>\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation. +- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. + +## Model Experience + +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + +## Known Limitations and Deferred Work + +- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path. +- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. +- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. +- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. +- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this. +- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. +- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. +- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md new file mode 100644 index 0000000000..9895449f6f --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -0,0 +1,93 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +[English](README.md) | 中文 + +面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 + +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 + +直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 + +## 用法 + +```ts +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const workspaceRoot = process.cwd() + +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. workspace-write +// REQUIRES the workspace's write SID — the per-workspace identity. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +``` + +直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 + +<a id="the-confinement-runner"></a> + +## 隔离 runner + +面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: + +```sh +node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...> +``` + +runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 + +**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 + +模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): +- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 + +Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 + +`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 + +## 头部验证 + +所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查: + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。 + +## 已验证边界(受限令牌固有,非本移植引入) + +- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。 +- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls <dir> /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 +- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 +- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`<temp>\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 +- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。 +- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 + +## Model Experience + +间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 + +#### KV Cache 影响 + +无直接影响;拒绝面属于工具层。 + +## Known Limitations and Deferred Work + +- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 +- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 +- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 +- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 +- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。 +- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 +- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 +- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json new file mode 100644 index 0000000000..2f13b71296 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-sandbox-windows-acl", + "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam", + "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" + }, + "./runner": { + "types": "./lib/types/runner.d.ts", + "default": "./lib/runner.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/runner.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "koffi": "^3.1.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts new file mode 100644 index 0000000000..ef787cc410 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -0,0 +1,271 @@ +/** + * ACL editing helpers: grant/revoke the orphan write SID on a directory via + * SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with + * the failure handling the POC lacks). Every API call is checked and every + * failure is reported with the API name, the exact Win32 code, the formatted + * system text, and the affected path. + * + * Concurrency: grants are read-merge-write against the directory's CURRENT + * DACL, and the whole get-merge-set sequence runs under a per-path exclusive + * LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances + * cannot clobber each other's ACEs. + * @module @deepseek-ai/dsh-sandbox-windows-acl/acl + */ + +import { createHash } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { allocOverlapped, allocPtrSlot, decodePtr, decodeUint8At, decodeUint16At, decodeUint32At, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, sameSidAt, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp): + * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16, + * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. + * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which + * removes every ACE for the trustee. + * @param sidPtr - the trustee SID the entry names. + * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS). + * @param permissions - the access mask to grant (0 for REVOKE_ACCESS). + * @returns the packed entry buffer. + */ +export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { + const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) + entry.writeUInt32LE(permissions, 0) // grfAccessPermissions + entry.writeUInt32LE(mode, 4) // grfAccessMode + entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI + entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation + entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm + entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType + entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID + return entry +} + +/** + * One lock file per protected path: `<GetTempPathW()>\dsh-acl-locks\<first 16 + * hex of sha256(lowercased path)>.lock`. The lock root derives from + * GetTempPathW (never from runner argv or DSH_HOME), and the lowercasing + * maps Windows's case-insensitive path spellings onto one lock. + * @param api - the binding table. + * @param path - the protected directory (absolute). + * @returns the lock file path for that directory. + */ +export function lockFilePath(api: Win32Bindings, path: string): string { + const digest = createHash('sha256').update(path.toLowerCase()).digest('hex').slice(0, 16) + return join(getTempPath(api), 'dsh-acl-locks', `${digest}.lock`) +} + +/** + * Run `action` holding the per-path exclusive lock: CreateFileW + * (OPEN_ALWAYS, shared read/write but NOT delete — a deletable lock file + * could be removed and recreated under the holder, letting two processes + * hold "the same" lock), then a one-byte LockFileEx + * (LOCKFILE_EXCLUSIVE_LOCK, zeroed OVERLAPPED = lock from offset 0 on the + * synchronous handle — see allocOverlapped for why not NULL), then + * UnlockFileEx + CloseHandle. Fail-closed: open/lock/unlock/close failures + * throw like every other Win32 call in this package; an `action` failure + * still unlocks (best-effort) and rethrows the original error. + * @param api - the binding table. + * @param path - the protected directory (absolute). + * @param action - the get-merge-set sequence to serialize. + * @returns the action's result. + */ +export function withPathLock<T>(api: Win32Bindings, path: string, action: () => T): T { + const lockPath = lockFilePath(api, path) + mkdirSync(dirname(lockPath), { recursive: true }) + const handle = api.createFileW( + lockPath, + abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, + null, abi.OPEN_ALWAYS, 0, null, + ) + if (isInvalidHandle(handle)) throwLastError(api, 'CreateFileW', lockPath) + const overlapped = allocOverlapped() // stays zeroed: offset 0, hEvent NULL + if (api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped) === 0) { + const win32Code = api.getLastError() + api.closeHandle(handle) // best-effort on the lock-failure path + throwWin32(api, 'LockFileEx', win32Code, lockPath) + } + + let result: T + try { + result = action() + } catch (error) { + // Best-effort release on the action-failure path: cleanup failures must + // not mask the action's error. + api.unlockFileEx(handle, 0, 1, 0, overlapped) + api.closeHandle(handle) + throw error + } + if (api.unlockFileEx(handle, 0, 1, 0, overlapped) === 0) { + const win32Code = api.getLastError() + api.closeHandle(handle) // best-effort on the unlock-failure path + throwWin32(api, 'UnlockFileEx', win32Code, lockPath) + } + if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', `lock file ${lockPath}`) + return result +} + +/** + * Read the directory's current explicit DACL via GetNamedSecurityInfoW. + * Allocation contract (the POC's RevokeAccess, minus its missing checks): the + * returned ACL pointer sits INSIDE the security descriptor allocation — only + * the descriptor may be LocalFree'd, and it must not be freed before + * SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself + * corrupts the heap (verified the hard way). + * @param api - the binding table. + * @param path - the directory whose DACL is read. + * @returns the current explicit DACL (null when the directory carries none) and its owning descriptor. + */ +function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) + return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) } +} + +/** + * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` + * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), + * free the descriptor before applying the merged ACL, apply it, then free the + * merged ACL — checking every call and reporting with the caller's label. + * @param api - the binding table. + * @param path - the directory the DACL edit applies to. + * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). + * @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}). + * @param descriptor - the descriptor allocation owning `oldAcl`. + * @param label - the caller's name for error details. + */ +function mergeAndApply( + api: Win32Bindings, + path: string, + entry: Buffer, + oldAcl: NativePtr | null, + descriptor: NativePtr | null, + label: string, +): void { + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot) + if (mergeResult !== abi.ERROR_SUCCESS) { + if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too + throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) + } + const newAcl = decodePtr(newAclSlot) + if (newAcl === null) { + if (descriptor !== null) api.localFree(descriptor) + throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`) + } + + // The descriptor block (oldAcl included) is dead after the merge — free it + // before applying, exactly like the POC. + const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null + const applyResult = api.setNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + null, null, newAcl, null, + ) + const freedNew = api.localFree(newAcl) + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) + if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) + if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) +} + +/** + * True when the explicit DACL already carries the EXACT write grant this + * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the + * orphan SID). Every field is read through koffi.decode at pointer offsets — + * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the + * ACE after the 4-byte mask — there is no pointer to read; reading one + * yields garbage addresses and crashed EqualSid, verified by gdb), so it is + * compared field-by-field against the orphan SID through bounded offset + * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" + * so the caller falls back to the merge-apply path, which owns the robust + * failure handling. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). + * @param sidPtr - the orphan write SID to match. + * @returns whether the exact grant ACE is already present. + */ +function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(oldAcl, 2) + const aceCount = decodeUint16At(oldAcl, 4) + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path + let offset = 8 // the first ACE follows the 8-byte ACL header + for (let index = 0; index < aceCount; index++) { + // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); + // ACCESS_ALLOWED_ACE: Mask@4, inline SID@8. + const aceSize = decodeUint16At(oldAcl, offset + 2) + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path + const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE + && decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK + if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + offset += aceSize + } + return false +} + +/** + * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID + * on `path`, inheriting to subcontainers and objects. Idempotent: when the + * directory's current explicit DACL already carries the exact ACE (the + * per-session grant surviving from a previous server lifetime), the + * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate + * the identical ACE across the whole tree (eager inheritance; minutes on + * large workspaces). Otherwise read-merge-write: the new ACE merges into the + * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so + * pre-existing explicit ACEs survive. Runs under the per-path lock. The + * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * precondition as the POC. + * @param api - the binding table. + * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param sidPtr - the orphan write SID the ACE names. + */ +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { + withPathLock(api, path, () => { + const { oldAcl, descriptor } = readCurrentDacl(api, path) + if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) { + // The exact ACE stands: releasing the descriptor is the whole operation. + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) + } + return + } + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') + }) +} + +/** + * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * merge — other entries are preserved). Returns whether an ACE removal was + * attempted (false when the directory carries no DACL at all). + * + * Runs under the per-path lock (the whole get-merge-set sequence); the + * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. + * @param api - the binding table. + * @param path - the directory whose DACL loses the orphan-SID ACEs. + * @param sidPtr - the orphan write SID whose ACEs are removed. + * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). + */ +export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { + return withPathLock(api, path, () => { + const { oldAcl, descriptor } = readCurrentDacl(api, path) + if (oldAcl === null) { + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + } + return false + } + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite') + return true + }) +} diff --git a/packages/sandbox/sandbox-windows-acl/src/errors.ts b/packages/sandbox/sandbox-windows-acl/src/errors.ts new file mode 100644 index 0000000000..b57d6dd466 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/errors.ts @@ -0,0 +1,21 @@ +/** + * Fail-closed Win32 error type. Every backend API failure raises this with the + * API name and the exact Win32 code; the original POC silently ignored every + * failed call and would run children UNRESTRICTED (fail-open) — that is the + * failure mode this class exists to prevent. + * @module @deepseek-ai/dsh-sandbox-windows-acl/errors + */ + +export class Win32Error extends Error { + /** The failing Win32 API name, e.g. `CreateRestrictedToken`. */ + readonly api: string + /** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */ + readonly win32Code: number + + constructor(api: string, win32Code: number, detail?: string) { + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + this.name = 'Win32Error' + this.api = api + this.win32Code = win32Code + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts new file mode 100644 index 0000000000..99b3cfaff3 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -0,0 +1,510 @@ +/** + * Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so + * non-Windows processes never open Win32 libraries. Every function signature + * below was verified against the MinGW Windows headers on this machine + * (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h / + * processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h); + * struct layouts are asserted at load time against verify/abi-probe.cpp. + * @module @deepseek-ai/dsh-sandbox-windows-acl/ffi + */ + +import koffi from 'koffi' +import { Win32Error } from './errors.ts' +import * as abi from './win32-abi.ts' + +/** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ +declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** + * True for NULL pointers, however koffi returns them (null or 0n). + * @param value - a pointer as koffi may hand it back (pointer, null, or 0n). + * @returns a type guard narrowing to the NULL shapes. + */ +export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { + return value === null || value === undefined || (value as bigint) === 0n +} + +/** + * True for CreateFileW's INVALID_HANDLE_VALUE failure marker (-1, which + * koffi hands back as the unsigned 64-bit all-ones pointer). + * @param handle - the handle CreateFileW returned. + * @returns whether the handle signals failure. + */ +export function isInvalidHandle(handle: NativePtr | null | undefined): boolean { + if (isNullPtr(handle)) return true + return (handle as bigint) === 0xFFFFFFFFFFFFFFFFn || (handle as bigint) === -1n +} + +type Ptr = ReturnType<typeof koffi.pointer> + +/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ +export interface StartupInfoInput { + cb: number + dwFlags: number + hStdInput: NativePtr + hStdOutput: NativePtr + hStdError: NativePtr +} + +/** Decoded PROCESS_INFORMATION (layout verified: size 24). */ +export interface ProcessInfoOutput { + hProcess: NativePtr | null + hThread: NativePtr | null + dwProcessId: number + dwThreadId: number +} + +/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */ +export interface Win32Bindings { + // ---- process / token handles -------------------------------------------- + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number + closeHandle(handle: NativePtr): number + // ---- errors / diagnostics ------------------------------------------------ + getLastError(): number + formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number + // ---- memory -------------------------------------------------------------- + localAlloc(flags: number, bytes: number): NativePtr + localFree(memory: NativePtr): NativePtr + // ---- SIDs ---------------------------------------------------------------- + convertStringSidToSidW(stringSid: string, sid: NativePtr): number + createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number + isValidSid(sid: NativePtr): number + getLengthSid(sid: NativePtr): number + copySid(length: number, destination: NativePtr, source: NativePtr): number + // ---- token information --------------------------------------------------- + getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number + // ---- restricted token ---------------------------------------------------- + createRestrictedToken( + existing: NativePtr, flags: number, + disableCount: number, disableSids: null, + deletePrivilegeCount: number, privilegesToDelete: null, + restrictCount: number, restrictingSids: Buffer, + newToken: NativePtr, + ): number + // ---- ACL editing --------------------------------------------------------- + setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + setNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: null, group: null, dacl: NativePtr | null, sacl: null, + ): number + getNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, + ): number + // ---- environment / io ---------------------------------------------------- + getTempPathW(length: number, buffer: Buffer): number + createFileW( + fileName: string, desiredAccess: number, shareMode: number, attributes: null, + creationDisposition: number, flagsAndAttributes: number, templateFile: null, + ): NativePtr + lockFileEx(file: NativePtr, flags: number, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number + unlockFileEx(file: NativePtr, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number + createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + setHandleInformation(handle: NativePtr, mask: number, flags: number): number + createProcessAsUserW( + token: NativePtr, applicationName: null, commandLine: string, + processAttributes: null, threadAttributes: null, + inheritHandles: number, creationFlags: number, environment: null, + currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, + ): number + setEnvironmentVariableW(name: string, value: string): number + readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number + peekNamedPipe( + pipe: NativePtr, buffer: null, size: number, + bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr, + ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number + getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number + resumeThread(thread: NativePtr): number + // ---- job object (runner kill-on-close) ----------------------------------- + createJobObjectW(attributes: null, name: null): NativePtr + setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + // Terminate a suspended child that could not be placed in the kill-on-close + // job — closing handles alone would leave it hanging forever. + terminateProcess(process: NativePtr, exitCode: number): number + // ---- console ------------------------------------------------------------- + // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): + // the runner survives console Ctrl+C so the child handles its own and the + // runner can clean up grants after the child exits. + setConsoleCtrlHandler(handler: null, add: number): number + getStdHandle(stdHandle: number): NativePtr +} + +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */ +export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { + cb: 'uint32', + lpReserved: 'str16', + lpDesktop: 'str16', + lpTitle: 'str16', + dwX: 'uint32', + dwY: 'uint32', + dwXSize: 'uint32', + dwYSize: 'uint32', + dwXCountChars: 'uint32', + dwYCountChars: 'uint32', + dwFillAttribute: 'uint32', + dwFlags: 'uint32', + wShowWindow: 'uint16', + cbReserved2: 'uint16', + lpReserved2: koffi.pointer('uint8'), + hStdInput: PVOID, + hStdOutput: PVOID, + hStdError: PVOID, +}) + +/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */ +export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { + hProcess: PVOID, + hThread: PVOID, + dwProcessId: 'uint32', + dwThreadId: 'uint32', +}) + +if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { + throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) +} +if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { + throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) +} + +/** + * Allocate one pointer-sized slot (for `T **` out-parameters). + * @returns the allocated slot pointer. + */ +export function allocPtrSlot(): NativePtr { + const value: unknown = koffi.alloc(PVOID, 1) + return value as NativePtr +} + +/** + * Allocate one uint32 slot. + * @returns the allocated slot pointer. + */ +export function allocUint32(): NativePtr { + const value: unknown = koffi.alloc('uint32', 1) + return value as NativePtr +} + +/** + * Write a uint32 value into a slot pointer. + * @param slot - the slot allocated by {@link allocUint32}. + * @param value - the uint32 to encode. + */ +export function encodeUint32(slot: NativePtr, value: number): void { + koffi.encode(slot, 'uint32', value) +} + +/** + * Decode the pointer stored in a pointer-sized slot (NULL becomes null). + * @param slot - the pointer-sized slot holding the out-parameter value. + * @returns the decoded pointer, or null for NULL. + */ +export function decodePtr(slot: NativePtr): NativePtr | null { + const value: unknown = koffi.decode(slot, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** + * Decode a uint32 at a slot pointer. + * @param slot - the uint32 slot holding the out-parameter value. + * @returns the decoded uint32. + */ +export function decodeUint32(slot: NativePtr): number { + const value: unknown = koffi.decode(slot, 'uint32') + return value as number +} + +/** + * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). + * @param ptr - the koffi pointer. + * @returns the pointer's numeric address. + */ +export function ptrAddress(ptr: NativePtr): bigint { + return koffi.address(ptr) +} + +/** + * Allocate a raw byte block (used for SID copies and variable-length arrays). + * @param length - the block size in bytes. + * @returns the allocated block pointer. + */ +export function allocBytes(length: number): NativePtr { + const value: unknown = koffi.alloc('uint8', length) + return value as NativePtr +} + +/** + * Allocate one zeroed OVERLAPPED (32 bytes on x64: Internal@0, InternalHigh@8, + * Offset@16, OffsetHigh@20, hEvent@24). LockFileEx/UnlockFileEx receive this + * instead of a NULL lpOverlapped: koffi 3.1.1 crashes on NULL there, and a + * zeroed OVERLAPPED on a synchronous file handle is the documented equivalent + * (the byte range locks from offset 0, hEvent stays NULL). + * @returns the zeroed block pointer. + */ +export function allocOverlapped(): NativePtr { + return allocBytes(32) +} + +/** + * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). + * @param buffer - the buffer holding the pointer value. + * @param offset - byte offset of the pointer inside the buffer. + * @returns the decoded pointer, or null for NULL. + */ +export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { + const value: unknown = koffi.decode(buffer, offset, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** + * Decode a uint8 at a native pointer plus byte offset — the ACL walk's + * field-read primitive (koffi.decode with an offset, no memcpy, no pointer + * arithmetic). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint8. + */ +export function decodeUint8At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint8') + return value as number +} + +/** + * Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint16. + */ +export function decodeUint16At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint16') + return value as number +} + +/** + * Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint32. + */ +export function decodeUint32At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint32') + return value as number +} + +/** + * Compare two SIDs field-by-field via BOUNDED offset reads (revision, count, + * identifier authority, subauthorities up to the count) — never a fixed-size + * struct decode, which would read past a short SID allocation (a SID with + * fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible + * subauthority count reads as unequal. + * @param left - pointer to one SID (offset 0). + * @param leftOffset - byte offset of the SID structure within `left`. + * @param right - pointer to the other SID. + * @param rightOffset - byte offset of the SID structure within `right`. + * @returns whether the SIDs are identical. + */ +export function sameSidAt(left: NativePtr, leftOffset: number, right: NativePtr, rightOffset: number): boolean { + const leftRevision = decodeUint8At(left, leftOffset) + const rightRevision = decodeUint8At(right, rightOffset) + if (leftRevision !== rightRevision) return false + const leftCount = decodeUint8At(left, leftOffset + 1) + const rightCount = decodeUint8At(right, rightOffset + 1) + if (leftCount !== rightCount || leftCount > abi.SID_MAX_SUB_AUTHORITIES) return false + for (let index = 0; index < 6; index++) { + if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false + } + for (let index = 0; index < leftCount; index++) { + if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false + } + return true +} + +/** + * Allocate a zeroed STARTUPINFOW. + * @returns the allocated struct pointer. + */ +export function allocStartupInfo(): NativePtr { + const value: unknown = koffi.alloc(STARTUPINFOW, 1) + return value as NativePtr +} + +/** + * Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). + * @param startupInfo - the allocated STARTUPINFOW to encode into. + * @param fields - the field subset to write. + */ +export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { + koffi.encode(startupInfo, STARTUPINFOW, fields) +} + +/** + * Allocate a zeroed PROCESS_INFORMATION. + * @returns the allocated struct pointer. + */ +export function allocProcessInfo(): NativePtr { + const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) + return value as NativePtr +} + +/** + * Decode a PROCESS_INFORMATION after CreateProcessAsUserW. + * @param processInfo - the PROCESS_INFORMATION filled by the spawn call. + * @returns the decoded handle/id fields. + */ +export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { + const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) + return value as ProcessInfoOutput +} + +let cached: Win32Bindings | undefined + +function bindings(): Win32Bindings { + if (cached !== undefined) return cached + const kernel32 = koffi.load('kernel32.dll') + const advapi32 = koffi.load('advapi32.dll') + + // Each binding shape is verified by verify/abi-probe.cpp against the real + // Windows headers and exercised end-to-end by tests/probe.spec.ts; the + // single cast keeps the per-binding noise out of this table. + const bind = (lib: ReturnType<typeof koffi.load>, name: string, result: Ptr | string, args: Array<Ptr | string>): unknown => + lib.func('__stdcall', name, result, args) + + cached = { + openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), + closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), + getLastError: bind(kernel32, 'GetLastError', 'uint32', []), + formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', ['uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID]), + localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), + localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), + convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), + createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), + isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), + getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), + copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), + getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), + setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']), + createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), + setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), + getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), + getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), + // fileapi.h line ~64: HANDLE CreateFileW(LPCWSTR, DWORD, DWORD, + // LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE). + createFileW: bind(kernel32, 'CreateFileW', PVOID, ['str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID]), + // fileapi.h lines ~177/~185: BOOL LockFileEx(HANDLE, DWORD, DWORD, DWORD, + // DWORD, LPOVERLAPPED); BOOL UnlockFileEx(HANDLE, DWORD, DWORD, DWORD, + // LPOVERLAPPED). lpOverlapped is NULL for synchronous locking. + lockFileEx: bind(kernel32, 'LockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID]), + unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), + createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), + createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ + PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', + koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + ]), + setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']), + readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), + peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), + waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), + getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), + createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), + setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), + setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), + getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), + } as unknown as Win32Bindings + return cached +} + +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ +export function win32(): Promise<Win32Bindings> { + return Promise.resolve(bindings()) +} + +/** + * Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's + * server-side per-session grant materializes ACEs inside the synchronous + * `confine()` call, which cannot await. Same cached table as {@link win32} + * (the underlying koffi loads are synchronous; the async wrapper exists for + * the runner's await-shaped call sites). + * @returns the cached binding table. + */ +export function win32Sync(): Win32Bindings { + return bindings() +} + +/** + * Turn a Win32 error code into readable text via FormatMessageW. + * @param api - the binding table. + * @param win32Code - the error code to format. + * @returns the formatted message text, or '' when formatting fails. + */ +export function errorText(api: Win32Bindings, win32Code: number): string { + const buffer = Buffer.alloc(1024) + const length = api.formatMessageW( + abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, + null, win32Code, 0, buffer, buffer.length / 2, null, + ) + if (length === 0) return '' + return buffer.subarray(0, length * 2).toString('utf16le').trim() +} + +/** + * Read the process temp directory via GetTempPathW (fileapi.h line ~188). + * Defensive against an overlong system temp path: GetTempPathW reports the + * REQUIRED length (including NUL) without writing the buffer when it is too + * small, so a reported length beyond the buffer's capacity means the buffer + * was never filled and must not be decoded. + * @param api - the binding table. + * @returns the NUL-terminated temp path decoded as a string. + */ +export function getTempPath(api: Win32Bindings): string { + const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) + const length = api.getTempPathW(buffer.length / 2, buffer) + if (length === 0) throwLastError(api, 'GetTempPathW') + if (length > buffer.length / 2) { + throw new Win32Error('GetTempPathW', abi.ERROR_INSUFFICIENT_BUFFER, `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`) + } + return buffer.subarray(0, length * 2).toString('utf16le') +} + +/** + * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately + * after the failed call so GetLastError is not clobbered by other Win32 calls. + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. + */ +export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { + const win32Code = api.getLastError() + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} + +/** + * Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param win32Code - the API's returned error code. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. + */ +export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts new file mode 100644 index 0000000000..7cfd6e1a36 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -0,0 +1,107 @@ +/** + * Server-side per-session write grant: the ACE materialization half of the + * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE + * {@link AclWriteGrant} per session for the server process's lifetime — + * created lazily at the session's first confined execution, reused (never + * re-applied) for every later call, revoked on provider dispose. The durable + * half (the session's SID and paths surviving a restart) lives in the + * session log, owned by the seam; this module owns only the native half: the + * parsed SID pointer and the standing ACEs. + * + * Fail-closed: `add` throws on any grant failure and the caller disposes the + * instance (revoking every path granted so far); `dispose` revokes every + * standing grant and reports every cleanup failure. + * @module @deepseek-ai/dsh-sandbox-windows-acl/grant + */ + +import { grantWrite, revokeWrite } from './acl.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' + +/** + * One write SID's server-lifetime grant materialization: the parsed SID + * pointer plus every directory whose DACL currently carries its ACE. + * Workspace paths are added STANDING (their ACEs are the cross-session reuse + * cache and outlive the grant — dispose() skips revoking them, or the next + * provision would re-propagate the whole tree); temp paths are revocable + * (dispose() revokes them — an inheritable ACE must not outlive its + * session's temp directory). Create with {@link AclWriteGrant.create}; + * dispose revokes the revocable paths and frees the SID. + */ +export class AclWriteGrant { + /** The write SID in SDDL string form. */ + readonly writeSid: string + private readonly api: Win32Bindings + private readonly sidPtr: NativePtr + private readonly revocablePaths: string[] = [] + private readonly standingPaths: string[] = [] + + private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { + this.api = api + this.sidPtr = sidPtr + this.writeSid = writeSid + } + + /** + * Parse the SID string and open the binding table (lazily, once per + * server). Fail-closed: any failure throws — nothing is granted yet. + * @param writeSid - the orphan write SID string (`S-1-4-x-y`). + * @param api - optional already-resolved bindings (tests). + * @returns the ready grant (no ACEs yet). + */ + static create(writeSid: string, api?: Win32Bindings): AclWriteGrant { + const bindings = api ?? win32Sync() + const sidSlot = allocPtrSlot() + if (bindings.convertStringSidToSidW(writeSid, sidSlot) === 0) { + throwLastError(bindings, 'ConvertStringSidToSidW', writeSid) + } + const sidPtr = decodePtr(sidSlot) + if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) + return new AclWriteGrant(bindings, sidPtr, writeSid) + } + + /** + * Grant the write ACE on one directory (idempotent: an already-standing + * exact ACE skips the eager full-tree re-propagation — see + * {@link grantWrite}) and record the path for {@link dispose} unless it is + * standing. The path is recorded BEFORE the grant: a post-apply throw (a + * LocalFree failure after SetNamedSecurityInfoW succeeded) must still + * revoke it, and revoking an ungranted path is a no-op merge. Callers + * treat a throw as a failed materialization and dispose the instance to + * revoke the paths granted so far. + * @param path - the directory whose DACL gains the grant. + * @param standing - the ACE outlives this grant (the workspace reuse + * cache; dispose() skips revoking it). Default false (revoked on + * dispose — the temp-directory lifecycle). + */ + add(path: string, standing = false): void { + ;(standing ? this.standingPaths : this.revocablePaths).push(path) + grantWrite(this.api, path, this.sidPtr) + } + + /** Every directory currently carrying the grant, in grant order. */ + get paths(): readonly string[] { + return [...this.standingPaths, ...this.revocablePaths] + } + + /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */ + dispose(): void { + const failures: unknown[] = [] + for (const path of this.revocablePaths) { + try { + revokeWrite(this.api, path, this.sidPtr) + } catch (error) { + failures.push(error) + } + } + try { + const freed = this.api.localFree(this.sidPtr) + if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts new file mode 100644 index 0000000000..efa966a441 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -0,0 +1,386 @@ +/** + * Windows ACL write-restriction sandbox backend for the DeepSeek Harness + * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ + * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED + * token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only + * this sandbox adds to the target directories' DACLs — the intersection + * check then allows writes exactly where that SID has a Write ACE, and + * nowhere else the write SID is concerned (the token's write check ALSO + * inherits the ambient write ACEs of the other restricting SIDs — the + * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, + * and LOCAL are absent from both lists — see the seam's dual-list contract + * in `packages/sandbox/sandbox-local` and the package README's Modes section + * for the complete boundary). The write SID is the per-WORKSPACE identity + * ({@link workspaceWriteSid}): deterministic from the canonical workspace + * path, so the workspace-root ACE materializes once per workspace per + * machine and every later provision hits the exact-ACE skip — the + * grant-reuse story the per-session random SID paid a full tree propagation + * per session for. Unlike the POC, every API failure throws with the API + * name and exact Win32 code; a child is NEVER spawned unrestricted. + * + * Known boundaries (inherent to restricted tokens, not this port): + * - writes are restricted; reads, network, and process visibility are NOT + * (WRITE_RESTRICTED intersects only write accesses); + * - console isolation is unavailable — children share the host console + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with + * STATUS_DLL_INIT_FAILED under the restriction); + * - the temp directory and every writable directory must be owned by the + * caller (owner-implicit WRITE_DAC); + * - grants are standing ACE mutations on real directories. WORKSPACE grants + * are deliberately never revoked — the ACE is the cross-session reuse + * cache (revoking would force the next session to re-propagate the whole + * tree). TEMP grants are revocable: dispose() removes them so a standing + * inheritable ACE never outlives its session's temp directory (an + * inheritable ACE on the ambient temp root would otherwise widen the + * SID's write reach to every future temp file). With `manageDacls: false` + * the CALLER owns the DACLs (the sandbox seam's grant reuse): + * init()/dispose() skip grant/revoke entirely and the caller must not + * revoke under live children. + * @module @deepseek-ai/dsh-sandbox-windows-acl + */ + +import { existsSync, statSync } from 'node:fs' +import { resolve } from 'node:path' + +import { grantWrite, revokeWrite } from './acl.ts' +import { Win32Error } from './errors.ts' +import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' +import * as abi from './win32-abi.ts' + +export { quoteArg } from './spawn.ts' +export { AclWriteGrant } from './grant.ts' +export { workspaceWriteSid } from './workspace-sid.ts' +export { Win32Error } from './errors.ts' + +/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ +export interface AclSandboxOptions { + /** Directories the confined child may write into (must exist and be caller-owned). */ + writableDirs: readonly string[] + /** + * Temp directory to also grant; defaults to GetTempPathW() at init time. + * Pass null for read-only confinement: NO temp grant (strict zero grant on + * the filesystem; the NUL device stays ambient-writable via Everyone — see + * README). + */ + tempDir?: string | null + /** + * The write SID forming the workspace-write allowlist: REQUIRED under + * workspace-write, ignored (and must be absent) under read-only. Callers + * derive it from the workspace via {@link workspaceWriteSid} — the identity + * is per workspace, not per sandbox instance, so the workspace-root ACE + * outlives every instance and later provisions hit the exact-ACE skip. + */ + writeSid?: string + /** + * The file-effect mode this instance confines under — selects the + * restricted token's restricting-SID list (I for read-only, J for + * workspace-write) and MUST match the grant shape: read-only pairs with + * zero grants. The runner validates the argv-borne mode string at its + * boundary; this typed seam trusts the union. + */ + mode: 'read-only' | 'workspace-write' + /** + * Whether this instance owns its DACL grants (default true). False means + * the CALLER has already materialized the ACEs (the sandbox seam's + * per-session grant reuse): init()/dispose() skip grant/revoke entirely — + * the caller holds the grants for its own lifetime and revokes them. + */ + manageDacls?: boolean +} + +/** Per-spawn options: the program, its argv/cwd, and the stdio shape. */ +export interface AclSandboxSpawnOptions { + /** Program to run (resolved via PATH search when unqualified, like CreateProcess). */ + command: string + /** Arguments, quoted per CommandLineToArgvW rules. */ + args?: readonly string[] + /** Working directory; defaults to the caller's cwd. */ + cwd?: string + /** + * 'pipe' (default): capture stdout/stderr via anonymous pipes. + * 'inherit': the child inherits the caller's stdio directly (runner usage — + * bytes flow straight through), always wrapped in a kill-on-close job so the + * child dies with the caller; stdout/stderr in the result are empty. + */ + stdio?: 'pipe' | 'inherit' +} + +/** A settled confined child: captured stdio and the exit code. */ +export interface AclSandboxChildResult { + stdout: Buffer + stderr: Buffer + exitCode: number +} + +/** A running confined child: its pid and a settlement promise. */ +export interface AclSandboxChild { + /** Child process id. */ + pid: number + /** Resolve stdout/stderr and the exit code once the child exits. */ + wait(): Promise<AclSandboxChildResult> +} + +/** + * One write-restricted sandbox instance: token + write-SID grants + spawn. + * `init()` is fail-closed — any Win32 failure revokes the revocable (temp) + * grants and throws; `dispose()` revokes the temp grants, leaves the + * standing workspace ACEs in place (the cross-instance reuse cache), frees + * every allocation, and reports every cleanup failure. With + * `manageDacls: false` the caller owns the grants (the sandbox seam's grant + * reuse): init() applies none and dispose() revokes none. + */ +export class AclSandbox { + /** Absolute writable directories (constructor-validated). */ + readonly writableDirs: string[] + /** The write SID string whose ACEs form the write allowlist (workspace-write only). */ + readonly writeSid: string | undefined + /** The file-effect mode — the restricted token's restricting-SID list selection. */ + readonly mode: 'read-only' | 'workspace-write' + private readonly tempDirOption: string | null | undefined + private readonly manageDacls: boolean + private tempDirResolved: string | null | undefined + private api: Win32Bindings | undefined + private token: NativePtr | undefined + private writeSidPtr: NativePtr | undefined + /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */ + private sidAllocations: NativePtr[] = [] + private grantedPaths: string[] = [] + + constructor(options: AclSandboxOptions) { + this.mode = options.mode + this.manageDacls = options.manageDacls ?? true + this.writableDirs = options.writableDirs.map((directory) => { + const absolute = resolve(directory) + if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { + throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`) + } + return absolute + }) + this.tempDirOption = options.tempDir + this.writeSid = options.writeSid + if (this.mode === 'workspace-write' && this.writeSid === undefined) { + throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()') + } + } + + /** Resolved temp directory (available after init; null when temp grants are disabled). */ + get tempDir(): string | null | undefined { + return this.tempDirResolved + } + + /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */ + async init(): Promise<void> { + if (this.api !== undefined) throw new Error('AclSandbox is already initialized') + const api = await win32() + + const currentToken = openCurrentProcessToken(api) + try { + // Read-only runs carry no write SID (its restricting list has no + // orphan): nothing to parse, nothing to grant. + let writeSidPtr: NativePtr | undefined + if (this.writeSid !== undefined) { + const sidSlot = allocPtrSlot() + if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + } + const parsedSid = decodePtr(sidSlot) + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) + this.writeSidPtr = parsedSid + writeSidPtr = parsedSid + } + + const tempDir = this.tempDirOption === null + ? null + : this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api) + if (tempDir !== null) { + if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { + throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) + } + this.tempDirResolved = tempDir + } + + // manageDacls: false — the caller (the sandbox seam's grant) already + // materialized the ACEs; this instance must neither add nor remove any. + // When this instance owns the DACLs, writableDir ACEs are STANDING (the + // per-workspace reuse cache — dispose() never revokes them, or the next + // provision would re-propagate the whole tree) and the temp ACE is + // REVOCABLE (dispose() removes it — an inheritable ACE on the ambient + // temp root must not outlive the instance, or it would widen the SID's + // write reach to every future temp file). + if (this.manageDacls) { + if (writeSidPtr !== undefined) { + for (const path of this.writableDirs) { + grantWrite(api, path, writeSidPtr) + } + if (tempDir !== null) { + // Record BEFORE granting: grantWrite can throw after a successful + // apply (a LocalFree failure), and the fail-closed catch must still + // revoke that path (revoking an ungranted path is a no-op merge). + this.grantedPaths.push(tempDir) + grantWrite(api, tempDir, writeSidPtr) + } + } + } + const logonSid = findLogonSid(api, currentToken) + this.sidAllocations.push(logonSid) + const worldSid = makeWellKnownSid(api, abi.WinWorldSid) + this.sidAllocations.push(worldSid) + const restricted = createRestrictedToken( + api, currentToken, logonSid, writeSidPtr, + { world: worldSid }, + this.mode, + ) + // The restricted token's default DACL still names only the user's + // ambient SIDs — none of the restricting SIDs. Every NEW object the + // confined process creates (anonymous stdio pipes, sync objects) takes + // its DACL from that default, so the write pass-2 check would deny + // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every + // piped-stdio grandchild spawn. Merge a full-access ACE for a + // restricting SID (the write SID under workspace-write, Everyone under + // read-only): new-object creation stays gated by the parent object's + // DACL, while the new object's own DACL passes pass-2. + setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid) + this.token = restricted + if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') + this.api = api + } catch (error) { + // Best-effort close on the failure path (last error already captured in `error`). + api.closeHandle(currentToken) + // Fail-closed cleanup: never leave a revocable (temp) grant or SID + // allocation behind a failed init. Standing workspace ACEs are NOT + // revoked — they are the intended end state (the reuse cache), not an + // error artifact. + const cleanupFailures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } + } + for (const sidPtr of this.sidAllocations.splice(0)) { + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures], + `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`, + ) + } + throw error + } + } + + /** + * Spawn a process under the restricted token. Fails closed: throws on every + * Win32 failure; the child is never created unrestricted. With + * `stdio: 'inherit'` the child shares the caller's stdio directly and is + * placed in a kill-on-close job (dies with the caller). Call dispose() only + * after all children have exited — revoking grants under a live child + * removes its remaining write allowance. + * @param options - the program, argv/cwd, and stdio shape. + * @returns the running child. + */ + spawn(options: AclSandboxSpawnOptions): AclSandboxChild { + const api = this.api + const token = this.token + if (api === undefined || token === undefined) throw new Error('AclSandbox is not initialized: call init() first') + const args = options.args ?? [] + const cwd = options.cwd ?? process.cwd() + + if (options.stdio === 'inherit') { + const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) + let exitCodePromise: Promise<number> | undefined + return { + pid: native.pid, + wait: async () => { + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + const exitCode = await exitCodePromise + if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + }, + } + } + + const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) + const stdout = drainPipe(api, native.stdoutRead) + const stderr = drainPipe(api, native.stderrRead) + // waitForExit is deliberately NOT started here: WaitForSingleObject blocks + // the thread and would starve the drains while the child is still running + // (pipe-buffer deadlock). The drains resolve only after the child closed + // its pipe ends — by then the wait returns immediately. + let exitCodePromise: Promise<number> | undefined + return { + pid: native.pid, + wait: async () => { + const stdoutBuffer = await stdout + const stderrBuffer = await stderr + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } + }, + } + } + + /** + * Revoke the revocable (temp) grants, free the SID, close the token; the + * standing workspace ACEs stay (the reuse cache). Reports every cleanup + * failure. + */ + dispose(): void { + const api = this.api + if (api === undefined) return + const failures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + if (this.manageDacls) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (error) { + failures.push(error) + } + } + } + try { + const freed = api.localFree(writeSidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + } + const token = this.token + if (token !== undefined) { + try { + if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') + } catch (error) { + failures.push(error) + } + } + for (const sidPtr of this.sidAllocations.splice(0)) { + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') + } catch (error) { + failures.push(error) + } + } + this.api = undefined + this.token = undefined + this.writeSidPtr = undefined + this.grantedPaths = [] + if (failures.length > 0) { + throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/invariant.ts b/packages/sandbox/sandbox-windows-acl/src/invariant.ts new file mode 100644 index 0000000000..35ea265a4b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-windows-acl`. + * @module @deepseek-ai/dsh-sandbox-windows-acl/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-windows-acl' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-windows-acl-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond the fail-closed contracts it enforces at each + * Win32 call boundary. + */ +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/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts new file mode 100644 index 0000000000..93f8cfcc01 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -0,0 +1,196 @@ +/** + * The windows-acl confinement runner: the argv-prefix wrapper the sandbox + * seam spawns in place of the caller's command. It creates the + * WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the + * wrapped argv under it with the CALLER'S stdio inherited (bytes flow + * straight through), mirrors the child's exit code, and revokes its temp + * grant on exit (workspace ACEs stay standing as the reuse cache). + * + * Stable argv contract (the seam builds it; a native-exe replacement would + * keep the same contract): + * [node, runner.js, '--workspace', <dir>, '--temp', <dir>, + * '--mode', <read-only|workspace-write>, + * ['--write-sid', <S-1-4-…>], '--', <argv...>] + * + * Modes: + * - workspace-write: the workspace and temp directories carry the orphan-SID + * Write grant; every other write is denied by the token intersection. + * - read-only: STRICT zero grants — no directory is writable, not even the + * NUL device (`> $null` fails with access denied); the restricting list + * carries no orphan SID, so a standing grant ACE from an earlier + * workspace-write period stays inert. BOTH modes drop Authenticated Users + * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the + * Public tree writes are denied); the two lists share the keep-alive group + * (logon SID, EVERYONE) and differ only by the orphan. + * + * `--write-sid`: the seam's grant contract — the CALLER has already + * materialized the write-SID ACEs (the seam's workspace + private-temp + * grants, server lifetime) and owns their revocation, so the runner neither + * grants nor revokes (manageDacls: false). The carried SID is the + * per-workspace identity ({@link workspaceWriteSid}) — the seam derives it + * from the policy root; the flag's PRESENCE is the seam-managed marker (its + * value must equal the workspace-derived SID). Absent `--write-sid` + * (standalone/test use) the runner self-manages grants per invocation with + * the same workspace-derived SID (its workspace ACEs are standing — the + * reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in + * workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN + * environment (SetEnvironmentVariableW) to the `--temp` directory — a + * PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs + * /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment + * NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in + * CreateProcessAsUserW, verified empirically). Read-only leaves the ambient + * temp entries untouched (writes there are denied anyway). + * + * Failure contract: every runner-side failure (bad args, missing + * directories, token/grant/spawn errors) prints `windows-acl-run: <detail>` + * to stderr and exits 127 — the seam's RUNNER_FAILURE_RULES matches that + * signature. The child is NEVER spawned unrestricted. + * @module @deepseek-ai/dsh-sandbox-windows-acl/runner + */ + +import { existsSync, statSync } from 'node:fs' + +import { win32 } from './ffi.ts' +import { AclSandbox } from './index.ts' +import { workspaceWriteSid } from './workspace-sid.ts' + +const RUNNER_SIGNATURE = 'windows-acl-run' +const RUNNER_FAILURE_EXIT = 127 + +class RunnerFailure extends Error {} + +/** Print the runner-failure signature line and unwind. */ +function fail(detail: string): never { + process.stderr.write(`${RUNNER_SIGNATURE}: ${detail}\n`) + throw new RunnerFailure(detail) +} + +interface ParsedArgs { + workspace: string + temp: string + mode: 'read-only' | 'workspace-write' + writeSid: string | undefined + command: string + args: string[] +} + +function parseArgs(raw: string[]): ParsedArgs { + let workspace: string | undefined + let temp: string | undefined + let mode: string | undefined + let writeSid: string | undefined + let index = 0 + for (; index < raw.length; index++) { + const token = raw[index] + if (token === '--') { + index++ + break + } + index++ + const value = raw[index] + if (value === undefined) fail(`missing value after ${token}`) + switch (token) { + case '--workspace': workspace = value; break + case '--temp': temp = value; break + case '--mode': mode = value; break + case '--write-sid': writeSid = value; break + default: fail(`unknown argument: ${token}`) + } + } + if (workspace === undefined) fail('missing --workspace') + if (temp === undefined) fail('missing --temp') + if (mode !== 'read-only' && mode !== 'workspace-write') fail(`unknown mode: ${String(mode)}`) + const argv = raw.slice(index) + const command = argv[0] + if (command === undefined) fail('missing command after --') + return { workspace, temp, mode, writeSid, command, args: argv.slice(1) } +} + +function requireDirectory(label: string, path: string): void { + if (!existsSync(path) || !statSync(path).isDirectory()) { + fail(`${label} is not an existing directory: ${path}`) + } +} + +async function main(): Promise<number> { + const parsed = parseArgs(process.argv.slice(2)) + // Both directories are validated in both modes: a provider bug that passes + // a bogus root must fail loudly at the runner boundary, never mid-child. + requireDirectory('--workspace', parsed.workspace) + requireDirectory('--temp', parsed.temp) + + const api = await win32() + // Ignore this process's own CTRL+C: the confined child (same console) keeps + // handling its own; the runner must survive to revoke grants and mirror the + // child's exit code. + if (api.setConsoleCtrlHandler(null, 1) === 0) { + fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) + } + + // The write SID is the per-workspace identity in BOTH flows; the flag's + // presence (seam-derived, or the self-managed derivation) selects who + // owns the DACLs below. + const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined + const sandbox = new AclSandbox({ + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, + mode: parsed.mode, + ...writeSid === undefined ? {} : { writeSid }, + // With --write-sid the seam owns the DACLs (workspace + private-temp + // grants): this invocation must neither add nor revoke ACEs. + manageDacls: parsed.writeSid === undefined, + }) + await sandbox.init() + + // The seam's per-session temp contract: under --write-sid, workspace-write + // children see the PRIVATE per-session temp subdirectory through TMP/TEMP + // (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment + // (SetEnvironmentVariableW) and the child inherits the block; self-managed + // and read-only runs keep the ambient entries. + if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) { + if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) + } + if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) + } + } + + try { + const child = sandbox.spawn({ + command: parsed.command, + args: parsed.args, + stdio: 'inherit', + }) + const result = await child.wait() + return result.exitCode + } finally { + // Cleanup failures must not mask the child's exit code: report and keep going. + try { + sandbox.dispose() + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } + } +} + +main().then( + (exitCode) => { + // Exit-code mirroring is full-width on Windows, verified empirically on + // this machine (Windows 11 build 26200, Node 24): a child that exits + // with the NTSTATUS 0xC0000005 (STATUS_ACCESS_VIOLATION) is read back + // by GetExitCodeProcess as the uint32 3221225477, and after + // process.exitCode = 3221225477 the parent observes exactly + // 3221225477 (spawnSync status). PowerShell's $LASTEXITCODE and cmd + // print the signed view (-1073741819), but no truncation or masking + // happens anywhere in the chain — the mirror contract holds for the + // full 32-bit range, so no re-mapping is needed. + process.exitCode = exitCode + }, + (error: unknown) => { + if (!(error instanceof RunnerFailure)) { + process.stderr.write(`${RUNNER_SIGNATURE}: ${error instanceof Error ? error.message : String(error)}\n`) + } + process.exitCode = RUNNER_FAILURE_EXIT + }, +) diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts new file mode 100644 index 0000000000..eafcb252ce --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -0,0 +1,357 @@ +/** + * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with + * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then + * asynchronous pipe draining and exit waiting. Console isolation + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this + * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED + * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is + * pipe-based and unaffected; the child shares the host console. + * @module @deepseek-ai/dsh-sandbox-windows-acl/spawn + */ + +import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Quote one argument per the CommandLineToArgvW parsing rules: backslashes + * are doubled only before a quote character — including the closing quote + * this function appends, so a trailing backslash run is doubled as well + * (otherwise an odd run would escape the closing quote into a literal + * character and corrupt the rest of the command line). Mirrors the CRT + * ArgvQuote behavior Microsoft documents for command-line arguments. + * @param argument - one argv entry to quote. + * @returns the quoted entry (bare when quoting is unnecessary). + */ +export function quoteArg(argument: string): string { + if (argument === '') return '""' + if (!/[\s"]/u.test(argument)) return argument + let quoted = '"' + for (let index = 0; index < argument.length; index++) { + let backslashes = 0 + while (index < argument.length && argument.charAt(index) === '\\') { + backslashes++ + index++ + } + if (index === argument.length) { + // Trailing backslash run: doubled so it cannot escape the closing quote. + quoted += '\\'.repeat(backslashes * 2) + } else if (argument.charAt(index) === '"') { + quoted += '\\'.repeat(backslashes * 2 + 1) + '"' + } else { + quoted += '\\'.repeat(backslashes) + argument.charAt(index) + } + } + return quoted + '"' +} + +/** + * Build the single command line CreateProcess parses from program + argv. + * @param program - the executable (argv[0]). + * @param args - the remaining argv entries. + * @returns the joined, quoted command line. + */ +export function buildCommandLine(program: string, args: readonly string[]): string { + return [program, ...args].map(quoteArg).join(' ') +} + +interface PipePair { + read: NativePtr + write: NativePtr +} + +function createPipe(api: Win32Bindings): PipePair { + const readSlot = allocPtrSlot() + const writeSlot = allocPtrSlot() + if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') + const read = decodePtr(readSlot) + const write = decodePtr(writeSlot) + if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle') + return { read, write } +} + +function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', label) + } +} + +/** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */ +export interface SpawnedNative { + pid: number + process: NativePtr + stdoutRead: NativePtr + stderrRead: NativePtr +} + +/** + * Create a process under the restricted token with piped stdio. The child's + * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends + * are returned for draining. The child inherits the caller's environment block + * (lpEnvironment NULL); the caller rewrites entries through + * SetEnvironmentVariableW before spawning (the runner's per-session temp + * contract) — passing an explicit block through koffi trips + * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically). + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles. + */ +export function spawnSandboxed( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedNative { + const stdIn = createPipe(api) + const stdOut = createPipe(api) + const stdErr = createPipe(api) + // Child side of each pipe must be inheritable (POC lines 262-268). + setInheritable(api, stdIn.read, 'stdin read end') + setInheritable(api, stdOut.write, 'stdout write end') + setInheritable(api, stdErr.write, 'stderr write end') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn.read, + hStdOutput: stdOut.write, + hStdError: stdErr.write, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: required for redirection + 0, // no creation flags: suspended/no-window variants are unusable under the restriction + null, options.cwd, + startupInfo, processInfo, + ) + // Capture the failure before CloseHandle calls clobber GetLastError, then + // close every pipe handle created so far — the six-close contract this test + // surface pins (tests/failure-paths.spec.ts). + if (created === 0) { + const win32Code = api.getLastError() + api.closeHandle(stdIn.read) + api.closeHandle(stdIn.write) + api.closeHandle(stdOut.read) + api.closeHandle(stdOut.write) + api.closeHandle(stdErr.read) + api.closeHandle(stdErr.write) + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + // Host-side cleanup: child handles are now duplicated in the child; the + // host closes its copies so ReadFile sees EOF when the child exits. + api.closeHandle(stdIn.read) + api.closeHandle(stdOut.write) + api.closeHandle(stdErr.write) + api.closeHandle(stdIn.write) + api.closeHandle(threadHandle) + + return { + pid: info.dwProcessId, + process: processHandle, + stdoutRead: stdOut.read, + stderrRead: stdErr.read, + } +} + +/** + * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. + * @param api - the binding table. + * @param handle - the pipe read end to drain (closed when done). + * @returns the complete pipe contents. + */ +export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise<Buffer> { + const chunks: Buffer[] = [] + for (;;) { + const bytesReadSlot = allocUint32() + const totalAvailSlot = allocUint32() + const leftThisMessageSlot = allocUint32() + const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot) + if (peeked === 0) { + const win32Code = api.getLastError() + if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF + throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) + } + const available = decodeUint32(totalAvailSlot) + if (available > 0) { + const chunk = Buffer.alloc(available) + const readSlot = allocUint32() + if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) { + throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) + } + chunks.push(chunk.subarray(0, decodeUint32(readSlot))) + } + // Small backoff instead of setImmediate: a bare next-tick would busy-poll + // the pipe at full event-loop speed while the child produces no output. + await new Promise<void>(resolve => setTimeout(resolve, 1)) + } + api.closeHandle(handle) + return Buffer.concat(chunks) +} + +/** + * Wait for process exit and return its exit code. Call only after both drains + * have resolved — the drains finish when the child closed its pipe ends, i.e. + * the child has already exited, so this wait returns immediately. Calling it + * earlier would block the event loop and starve the drains (the pipe-buffer + * deadlock the POC comments warn about). + * @param api - the binding table. + * @param process - the child process handle (closed when done). + * @returns the child's exit code. + */ +export function waitForExit(api: Win32Bindings, process: NativePtr): number { + const waitResult = api.waitForSingleObject(process, abi.INFINITE) + if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') + const exitCodeSlot = allocUint32() + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + api.closeHandle(process) + return decodeUint32(exitCodeSlot) +} + +/** + * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at + * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout + * verified by abi-probe.cpp). When the caller dies with the job handle open, + * Windows terminates every process in the job — the orphan-child backstop. + * The caller keeps the returned handle open for the child's lifetime. + */ +function createKillOnCloseJob(api: Win32Bindings): NativePtr { + const job = api.createJobObjectW(null, null) + if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') + const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) + information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET) + if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'SetInformationJobObject', win32Code) + } + return job +} + +/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */ +export interface SpawnedInherited { + pid: number + process: NativePtr + /** Kill-on-close job the child was placed in; caller closes it after the child exits. */ + job: NativePtr +} + +/** + * Create a process under the restricted token whose stdio passes straight + * through to the caller's pipes. This is the runner shape: the harness spawns + * the runner with piped stdio, and the runner's confined child writes to + * those same pipes. + * + * Node clears the inheritability of its stdio handles at startup + * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit + * bit around the call (libuv instead duplicates the handles; re-enabling is + * equivalent here and cheaper) and pass them explicitly via + * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles + * ("The handle is invalid", verified the hard way). The child starts + * suspended so it can be assigned to a kill-on-close job before it runs. + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles and job. + */ +export function spawnSandboxedInherited( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedInherited { + const job = createKillOnCloseJob(api) + const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE) + const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE) + const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE) + if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) { + api.closeHandle(job) + throwLastError(api, 'GetStdHandle', 'null standard handle') + } + + const makeInheritable = (handle: NativePtr, label: string): void => { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) + } + } + const restoreInherit = (handle: NativePtr): void => { + // Best-effort hygiene: the runner spawns nothing else; failures here must + // not mask the child outcome, so the result is deliberately unchecked. + api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } + makeInheritable(stdIn, 'stdin') + makeInheritable(stdOut, 'stdout') + makeInheritable(stdErr, 'stderr') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn, + hStdOutput: stdOut, + hStdError: stdErr, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: the re-enabled std handles must be inheritable + abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution + null, options.cwd, + startupInfo, processInfo, + ) + restoreInherit(stdIn) + restoreInherit(stdOut) + restoreInherit(stdErr) + if (created === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + api.closeHandle(job) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + if (api.assignProcessToJobObject(job, processHandle) === 0) { + // The child was created suspended and is NOT in the kill-on-close job: + // closing handles would leave it suspended forever. Terminate it first, + // then drop the handles and throw. + const win32Code = api.getLastError() + api.terminateProcess(processHandle, 1) + api.closeHandle(threadHandle) + api.closeHandle(processHandle) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(threadHandle) === 0xFFFFFFFF) { + // Closing the job triggers kill-on-close, so the suspended child dies + // instead of hanging until this process exits; the process/thread handles + // must go too. + const win32Code = api.getLastError() + api.closeHandle(threadHandle) + api.closeHandle(processHandle) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } + api.closeHandle(threadHandle) + + return { pid: info.dwProcessId, process: processHandle, job } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts new file mode 100644 index 0000000000..e6254acc03 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -0,0 +1,220 @@ +/** + * Restricted-token construction: open the current process token, extract its + * logon SID, build the well-known SIDs, and call CreateRestrictedToken with + * the POC's restricting-SID allowlist. Every API call is checked; any failure + * throws with the API name and the exact Win32 code — the original POC ignored + * all of these and silently ran children with the FULL, unrestricted token. + * @module @deepseek-ai/dsh-sandbox-windows-acl/token + */ + +import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import { buildExplicitAccess } from './acl.ts' +import * as abi from './win32-abi.ts' + +/** + * Open the current process's access token with the rights + * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token + * handle is obtained through a real OpenProcess handle because the + * GetCurrentProcess() pseudo-handle is not addressable through koffi). + * @param api - the binding table. + * @returns the opened token handle. + */ +export function openCurrentProcessToken(api: Win32Bindings): NativePtr { + const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid) + if (isNullPtr(processHandle)) throwLastError(api, 'OpenProcess', `pid ${process.pid}`) + + const tokenSlot = allocPtrSlot() + const opened = api.openProcessToken( + processHandle, + abi.TOKEN_QUERY | abi.TOKEN_DUPLICATE | abi.TOKEN_ADJUST_DEFAULT | abi.TOKEN_ASSIGN_PRIMARY, + tokenSlot, + ) + if (opened === 0) { + const win32Code = api.getLastError() + api.closeHandle(processHandle) // best-effort on the error path + throwWin32(api, 'OpenProcessToken', win32Code, `pid ${process.pid}`) + } + if (api.closeHandle(processHandle) === 0) throwLastError(api, 'CloseHandle', 'OpenProcess process handle') + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'OpenProcessToken', api.getLastError(), 'null token handle') + return token +} + +/** + * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute + * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and + * other per-logon objects; the POC extracts it the same way. + * @param api - the binding table. + * @param token - the token whose groups are scanned. + * @returns a copied logon SID (thrown when the token carries none). + */ +export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { + const neededSlot = allocUint32() + api.getTokenInformation(token, abi.TokenGroups, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER + const needed = decodeUint32(neededSlot) + if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenGroups size query') + if (needed < abi.TOKEN_GROUPS_OFFSET) throwWin32(api, 'GetTokenInformation', api.getLastError(), `implausible TokenGroups size ${needed}`) + + const groups = Buffer.alloc(needed) + if (api.getTokenInformation(token, abi.TokenGroups, groups, groups.length, neededSlot) === 0) { + throwLastError(api, 'GetTokenInformation', 'TokenGroups') + } + const groupCount = groups.readUInt32LE(0) + for (let index = 0; index < groupCount; index++) { + const sidPtr = decodePtrAt(groups, abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE) + const attributes = groups.readUInt32LE(abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE + 8) + // >>> 0: JS bitwise & is signed 32-bit; SE_GROUP_LOGON_ID has bit 31 set. + const isLogonId = ((attributes & abi.SE_GROUP_LOGON_ID) >>> 0) === (abi.SE_GROUP_LOGON_ID >>> 0) + if (sidPtr === null || !isLogonId) continue + const sidLength = api.getLengthSid(sidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', `logon SID group ${index}`) + const copy = allocBytes(sidLength) + if (api.copySid(sidLength, copy, sidPtr) === 0) throwLastError(api, 'CopySid', `logon SID group ${index}`) + return copy + } + throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`) +} + +/** + * Create one well-known SID (68-byte buffer) and assert its validity. + * @param api - the binding table. + * @param type - the WELL_KNOWN_SID_TYPE to create. + * @returns the created SID pointer. + */ +export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { + const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE) + const sizeSlot = allocUint32() + encodeUint32(sizeSlot, abi.SECURITY_MAX_SID_SIZE) + if (api.createWellKnownSid(type, null, sid, sizeSlot) === 0) { + throwLastError(api, 'CreateWellKnownSid', `type ${type}`) + } + if (api.isValidSid(sid) === 0) throwLastError(api, 'IsValidSid', `CreateWellKnownSid type ${type}`) + return sid +} + +/** + * Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL + * — the DACL every NEW object the token holder creates (without an explicit + * security descriptor) takes. The restricted token inherits the user's + * default DACL verbatim, which names no restricting SID: a new anonymous pipe + * (child stdio) therefore fails the write pass-2 check at creation + * (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every + * piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the + * write SID under workspace-write, Everyone under read-only), so each new + * object's own DACL passes pass-2 while object creation itself stays gated by + * the parent container's DACL (files outside the granted trees remain + * uncreatable). Fails closed: any Win32 failure throws before the spawn. + * @param api - the binding table. + * @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT). + * @param sidPtr - the restricting SID whose full-access ACE joins the default DACL. + */ +export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void { + const neededSlot = allocUint32() + api.getTokenInformation(token, abi.TokenDefaultDacl, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER + const needed = decodeUint32(neededSlot) + if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl size query') + const buffer = Buffer.alloc(needed) + if (api.getTokenInformation(token, abi.TokenDefaultDacl, buffer, buffer.length, neededSlot) === 0) { + throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl') + } + const currentDacl = decodePtrAt(buffer, 0) + if (currentDacl === null) { + throw new Error('setTokenDefaultDaclGrant: the token carries no default DACL to extend') + } + const newDaclSlot = allocPtrSlot() + const result = api.setEntriesInAclW( + 1, + buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.FILE_ALL_ACCESS), + currentDacl, + newDaclSlot, + ) + if (result !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', result, 'default DACL merge') + const newDacl = decodePtr(newDaclSlot) + if (newDacl === null) throwWin32(api, 'SetEntriesInAclW', result, 'null merged default DACL') + // TOKEN_DEFAULT_DACL { PACL DefaultDacl; } — the struct is exactly the + // pointer; SetTokenInformation copies the ACL before returning. + const info = Buffer.alloc(8) + info.writeBigUInt64LE(newDacl, 0) + if (api.setTokenInformation(token, abi.TokenDefaultDacl, info, info.length) === 0) { + const win32Code = api.getLastError() + api.localFree(newDacl) + throwWin32(api, 'SetTokenInformation', win32Code, 'TokenDefaultDacl') + } + api.localFree(newDacl) +} + +/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ +function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { + const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) + sids.forEach((sid, index) => { + buffer.writeBigUInt64LE(ptrAddress(sid), abi.SID_AND_ATTRIBUTES_SIZE * index) + }) + return buffer +} + +/** The well-known SID packed into every restricted token's restricting list. */ +export interface RestrictingSidSet { + world: NativePtr +} + +/** + * Create the write-restricted token with the mode-selected restricting list + * (verified on Win11 26200, see the POC-worktree restrict-variant harness): + * - read-only: [logon SID, EVERYONE] + * - workspace-write: [logon SID, EVERYONE, orphan] + * + * The logon SID + EVERYONE keep-alive group is shared by both modes: early + * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — + * pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY + * workspace-write — read-only carries no write SID, so a standing grant ACE + * from an earlier workspace-write period (a `/permission` mode downgrade, or + * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED + * pass-2 check grants only what the restricting list carries, keeping + * read-only strictly zero-grant even with stale ACEs standing, while the + * unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no + * re-propagation). Authenticated Users is absent from BOTH lists: the WMI + * namespace security check fails (0x80041003), so CIM is unavailable in + * every confined mode, and the C:\-root tree-creation escape (standing + * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in + * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's + * Public tree grants write to INTERACTIVE, so removing it closes that + * escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts + * for the verified failure modes. FAILS CLOSED: any failure throws — never + * spawn unrestricted. + * @param api - the binding table. + * @param currentToken - the process token to restrict. + * @param logonSid - the copied logon session SID. + * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only). + * @param known - the well-known SIDs entering the restricting list. + * @param mode - selects the restricting list (workspace-write adds the write SID). + * @returns the restricted token handle. + */ +export function createRestrictedToken( + api: Win32Bindings, + currentToken: NativePtr, + logonSid: NativePtr, + writeSid: NativePtr | undefined, + known: RestrictingSidSet, + mode: 'read-only' | 'workspace-write', +): NativePtr { + const restrictingSids = buildRestrictingSids(mode === 'read-only' + ? [logonSid, known.world] + : writeSid === undefined + ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })() + : [logonSid, known.world, writeSid]) + const tokenSlot = allocPtrSlot() + const created = api.createRestrictedToken( + currentToken, + abi.DISABLE_MAX_PRIVILEGE | abi.LUA_TOKEN | abi.WRITE_RESTRICTED, + 0, null, // no SIDs disabled + 0, null, // no privileges deleted + restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE, + restrictingSids, + tokenSlot, + ) + if (created === 0) throwLastError(api, 'CreateRestrictedToken', `restricting SIDs: ${restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE}`) + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'CreateRestrictedToken', api.getLastError(), 'null token handle') + return token +} diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts new file mode 100644 index 0000000000..8e85eced3c --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -0,0 +1,258 @@ +/** + * Windows ABI constants for the ACL-sandbox backend. + * + * Every value was verified against the actual MinGW Windows headers on this + * machine (C:\Strawberry\c\x86_64-w64-mingw32\include\) and cross-checked at + * runtime by verify/abi-probe.cpp (same numbers; static_asserts passed). + * Regenerate the probe with: + * g++ -std=c++20 -municode -O2 -o abi-probe.exe abi-probe.cpp -ladvapi32 && .\abi-probe.exe + * + * The port intentionally excludes two pieces of the original POC + * (github.com/huoyaoyuan/windows-acl-restrict-poc @ 10e4dfb), both verified + * empirically on Windows 11 build 26200: + * - S-1-2-1 (console logon SID) in the restricting list: the POC created it + * via CreateWellKnownSid(WinLocalLogonSid) which fails here with + * ERROR_INVALID_PARAMETER (87), leaving a garbage SID that makes + * CreateRestrictedToken fail with ERROR_INVALID_SID (1337); using the + * correct WinConsoleLogonSid does produce a valid S-1-2-1, but the child + * then still dies with STATUS_DLL_INIT_FAILED (0xC0000142) whenever + * CREATE_NO_WINDOW / CREATE_NEW_CONSOLE is used. + * - Console isolation: under this restriction scheme a hidden console is not + * attainable, so children share the host console (stdio redirection is + * pipe-based and unaffected). + * @module @deepseek-ai/dsh-sandbox-windows-acl/win32-abi + */ + +// ---- winnt.h --------------------------------------------------------------- + +// TOKEN_* access rights (winnt.h lines ~3928) +/** TOKEN_ASSIGN_PRIMARY: required to create a process with the token (CreateProcessAsUser). */ +export const TOKEN_ASSIGN_PRIMARY = 0x0001 +/** TOKEN_DUPLICATE: required to duplicate a token (DuplicateTokenEx). */ +export const TOKEN_DUPLICATE = 0x0002 +/** TOKEN_QUERY: required to read token information (GetTokenInformation). */ +export const TOKEN_QUERY = 0x0008 +/** TOKEN_ADJUST_DEFAULT: required to change a token's default DACL. */ +export const TOKEN_ADJUST_DEFAULT = 0x0080 + +// SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) +/** + * SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with + * `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number). + */ +export const SE_GROUP_LOGON_ID = 0xC0000000 + +// Generic file access (winnt.h lines ~5893-5913): +// FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES +// | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE +/** STANDARD_RIGHTS_WRITE (== READ_CONTROL): the standard-rights component of generic write access. */ +export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL +/** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ +export const FILE_GENERIC_WRITE = 0x00120116 +/** DELETE: remove or rename the object (winnt.h line ~3009). */ +export const DELETE = 0x00010000 +/** FILE_DELETE_CHILD: remove or rename a directory's children (winnt.h line ~5907). */ +export const FILE_DELETE_CHILD = 0x0040 +// The POC granted FILE_GENERIC_WRITE minus READ_CONTROL, which displays as +// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). The +// sandbox grant adds DELETE and FILE_DELETE_CHILD so confined +// delete/rename/git operations inside the granted trees pass the token's +// access check too; Write+DELETE displays as "Modify" in icacls. +// WRITE_DAC/WRITE_OWNER stay OUT deliberately — granting them would let the +// child take ownership or rewrite DACLs and escape the allowlist (the +// security boundary). +/** + * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and + * FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant + * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are + * deliberately excluded: they would let the confined child take ownership or + * rewrite DACLs. + */ +export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156 + +/** + * FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE + * | 0x1FF): full file-object access. The mask of the ACE merged into the + * restricted token's DEFAULT DACL — the token holder must keep full access to + * every NEW object it creates (pipes included), and the ACE must name a + * restricting SID so the write pass-2 check passes at creation. + */ +export const FILE_ALL_ACCESS = 0x1F01FF + +// CreateRestrictedToken flags (winnt.h lines ~4284) +/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ +export const DISABLE_MAX_PRIVILEGE = 0x1 +/** LUA_TOKEN: produce a limited-user (filtered admin) token. */ +export const LUA_TOKEN = 0x4 +/** WRITE_RESTRICTED: intersect write access with the restricting SIDs' ACL grants — the sandbox's core mechanism. */ +export const WRITE_RESTRICTED = 0x8 + +// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) +/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */ +export const WinWorldSid = 1 + +// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) +/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ +export const TokenGroups = 2 +/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */ +export const TokenDefaultDacl = 6 + +// SECURITY_INFORMATION (winnt.h line ~4293) +/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ +export const DACL_SECURITY_INFORMATION = 0x00000004 + +// PROCESS access rights (winnt.h lines ~4364) +/** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */ +export const PROCESS_QUERY_INFORMATION = 0x0400 + +// ---- accctrl.h ------------------------------------------------------------- + +// SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) +/** SE_FILE_OBJECT: the trustee path names a filesystem object. */ +export const SE_FILE_OBJECT = 1 + +// TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 +/** TRUSTEE_IS_UNKNOWN: TRUSTEE_TYPE unknown (TrusteeForm carries the shape). */ +export const TRUSTEE_IS_UNKNOWN = 0 +/** TRUSTEE_IS_SID: TRUSTEE_FORM — Trustee.ptstrName is a SID pointer. */ +export const TRUSTEE_IS_SID = 0 +/** NO_MULTIPLE_TRUSTEE: Trustee.pMultipleTrustee is null. */ +export const NO_MULTIPLE_TRUSTEE = 0 + +// ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) +/** GRANT_ACCESS: SetEntriesInAclW adds the entry as an allow ACE. */ +export const GRANT_ACCESS = 1 +/** REVOKE_ACCESS: SetEntriesInAclW removes the matching allow ACE. */ +export const REVOKE_ACCESS = 4 + +// grfInheritance (accctrl.h lines ~137-142) +/** + * SUB_CONTAINERS_AND_OBJECTS_INHERIT: the ACE applies to the directory, its + * subdirectories, and files (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE). + */ +export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + +// ---- winbase.h ------------------------------------------------------------- + +/** + * STARTF_USESTDHANDLES: STARTUPINFOW dwFlags — the child uses the hStd* + * handles, required because Node clears stdio inheritability at startup. + */ +export const STARTF_USESTDHANDLES = 0x00000100 +/** HANDLE_FLAG_INHERIT: SetHandleInformation flag re-enabling handle inheritance for the spawned child's stdio handles. */ +export const HANDLE_FLAG_INHERIT = 0x1 +/** INFINITE: never-timeout wait value. */ +export const INFINITE = 0xFFFFFFFF +/** MAX_PATH: legacy path length bound. */ +export const MAX_PATH = 260 +// winbase.h line ~410: the confined child starts suspended so the runner can +// assign it to the kill-on-close job before any of its code runs. +/** CREATE_SUSPENDED: create the child with its primary thread suspended until ResumeThread. */ +export const CREATE_SUSPENDED = 0x4 +// winbase.h lines ~497-499: GetStdHandle selectors. +/** STD_INPUT_HANDLE: GetStdHandle selector for the standard input. */ +export const STD_INPUT_HANDLE = -10 +/** STD_OUTPUT_HANDLE: GetStdHandle selector for the standard output. */ +export const STD_OUTPUT_HANDLE = -11 +/** STD_ERROR_HANDLE: GetStdHandle selector for the standard error. */ +export const STD_ERROR_HANDLE = -12 + +// FormatMessageW flags (winbase.h lines ~1446-1469) +/** FORMAT_MESSAGE_FROM_SYSTEM: format the message from the system message table. */ +export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +/** FORMAT_MESSAGE_IGNORE_INSERTS: skip insert-sequence substitution. */ +export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 + +// ---- error codes ----------------------------------------------------------- + +/** ERROR_SUCCESS: the operation succeeded. */ +export const ERROR_SUCCESS = 0 +/** ERROR_INSUFFICIENT_BUFFER: a size-probe call succeeded but needs a larger buffer. */ +export const ERROR_INSUFFICIENT_BUFFER = 122 +/** ERROR_BROKEN_PIPE: the pipe's other end has closed. */ +export const ERROR_BROKEN_PIPE = 109 +/** ERROR_NO_DATA: the pipe is being closed. */ +export const ERROR_NO_DATA = 232 +/** ERROR_LOCK_VIOLATION: a byte-range lock conflicts with an existing lock (winerror.h line ~78). */ +export const ERROR_LOCK_VIOLATION = 33 + +// ---- lock files (fileapi.h / minwinbase.h / winnt.h) ----------------------- + +// CreateFileW dwDesiredAccess for the ACL lock files: plain read+write is +// enough to take byte-range locks. +/** GENERIC_READ: generic read access (winnt.h line ~3028). */ +export const GENERIC_READ = 0x80000000 +/** GENERIC_WRITE: generic write access (winnt.h line ~3029). */ +export const GENERIC_WRITE = 0x40000000 +// CreateFileW dwShareMode: the lock file is shared for read/write but NOT +// for delete — if a locked file could be deleted and recreated underneath the +// lock holder, two processes could hold "the same" lock on different files. +/** FILE_SHARE_READ: other opens may read (winnt.h line ~5949). */ +export const FILE_SHARE_READ = 0x00000001 +/** FILE_SHARE_WRITE: other opens may write (winnt.h line ~5950). */ +export const FILE_SHARE_WRITE = 0x00000002 +/** FILE_SHARE_DELETE: other opens may delete (winnt.h line ~5951) — deliberately NOT used for lock files. */ +export const FILE_SHARE_DELETE = 0x00000004 +/** OPEN_ALWAYS: create the lock file if absent, open it otherwise (fileapi.h line ~21). */ +export const OPEN_ALWAYS = 4 +// LockFileEx dwFlags (minwinbase.h lines ~180-181, included by winbase.h). +/** LOCKFILE_EXCLUSIVE_LOCK: request an exclusive byte-range lock. */ +export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 +/** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ +export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 + +// ACE_HEADER.AceType (winnt.h lines ~3449-3463) +/** ACCESS_ALLOWED_ACE_TYPE: an access-allowed ACE granting the mask to the trustee. */ +export const ACCESS_ALLOWED_ACE_TYPE = 0 + +// SID structure (winnt.h line ~280 SID_IDENTIFIER_AUTHORITY; line ~286 +// #define SID_MAX_SUB_AUTHORITIES 15). +/** SID_MAX_SUB_AUTHORITIES: the most subauthorities a SID may carry. */ +export const SID_MAX_SUB_AUTHORITIES = 15 + +// ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when +// reading a DACL are marked with this bit and are not part of the explicit +// DACL edits this module makes. +/** INHERITED_ACE: the ACE was inherited from the parent object, not stored explicitly. */ +export const INHERITED_ACE = 0x10 + +// ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- + +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last +// job handle closes — the orphan-child backstop for the runner design. +/** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */ +export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +// JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. +/** JobObjectExtendedLimitInformation: JOBOBJECTINFOCLASS for the extended limit structure. */ +export const JobObjectExtendedLimitInformation = 9 +// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. +/** sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. */ +export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 +// LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION +// (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), +// verified by abi-probe. +/** + * LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION + * (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + + * PerJobUserTimeLimit@8), verified by abi-probe. + */ +export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 + +// ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- + +/** SECURITY_MAX_SID_SIZE: maximum SID byte size. */ +export const SECURITY_MAX_SID_SIZE = 68 +/** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ +export const SID_AND_ATTRIBUTES_SIZE = 16 +/** TOKEN_GROUPS.Groups[] starts at offset 8 (GroupCount @0 + alignment). */ +export const TOKEN_GROUPS_OFFSET = 8 +/** sizeof(EXPLICIT_ACCESS_W): perms@0 mode@4 inheritance@8 Trustee@16. */ +export const EXPLICIT_ACCESS_W_SIZE = 48 +/** Trustee offset inside EXPLICIT_ACCESS_W. */ +export const TRUSTEE_W_OFFSET = 16 +/** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ +export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 +/** sizeof(STARTUPINFOW), verified by abi-probe. */ +export const STARTUPINFOW_SIZE = 104 +/** sizeof(PROCESS_INFORMATION), verified by abi-probe. */ +export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts new file mode 100644 index 0000000000..db74893f36 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -0,0 +1,38 @@ +/** + * The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived + * from the canonical workspace path, whose ACEs form that workspace's write + * allowlist. Every confined execution of the same workspace — across + * sessions, server restarts, and calls — carries the SAME write SID, so the + * workspace-root ACE materializes once per workspace per machine (the + * grant's exact-ACE skip then makes every later provision O(1)) instead of + * once per session. The SID's power is defined solely by the ACEs that name + * it (which exist only on the workspace tree and the session's private temp + * directory), and only tokens minted for that workspace carry it — the SID + * string itself is not a secret (the previous per-session SID was likewise + * logged in the plain). + * + * The input MUST be the canonical workspace path (`realpathSync.native` on + * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it): + * canonicalization converges case/alias spellings, so two spellings of one + * workspace derive one SID; an as-spelled fallback path would mint a second + * identity for the same directory (self-healing, at the cost of one extra + * tree propagation). Renaming the workspace directory derives a new SID — + * the old standing ACEs are inert residue, and the next session re-propagates + * once. + * @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid + */ + +import { createHash } from 'node:crypto' + +/** + * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit, + * matching the orphan shape the token and ACE layers already carry). + * @param workspaceRoot - the canonical workspace path. + * @returns the SDDL string form. + */ +export function workspaceWriteSid(workspaceRoot: string): string { + const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest() + const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1 + const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 + return `S-1-4-${first}-${second}` +} diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts new file mode 100644 index 0000000000..25abe14392 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -0,0 +1,281 @@ +/** + * ACL edit tests: the read-merge-write grant keeps pre-existing explicit + * ACEs, interleaved sandbox instances do not clobber each other, the + * per-path lock primitive is deterministic, and the grant mask carries + * DELETE + FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER). + * + * All state lives in %TEMP% mkdtemp scratch directories; the only exception + * is the mandated lock infrastructure under <GetTempPathW()>\dsh-acl-locks, + * whose per-test lock file is removed in cleanup. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' +import { AclSandbox } from '../src/index.ts' +import { createRestrictedToken } from '../src/token.ts' +import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import * as abi from '../src/win32-abi.ts' + +const isWin32 = process.platform === 'win32' + +/** FILE_READ_DATA (winnt.h line ~5895): the harmless mask the explicit test ACE grants. */ +const FILE_READ_DATA = 0x0001 + +/** koffi SID layout: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes, big-endian), subAuthority@8. */ +const SID_STRUCT = koffi.struct('DSH_ACL_SPEC_SID', { + revision: 'uint8', + subAuthorityCount: 'uint8', + identifierAuthority: 'uint8[6]', + subAuthority: 'uint32[8]', +}) + +interface SidLayout { + revision: number + subAuthorityCount: number + identifierAuthority: number[] + subAuthority: number[] +} + +/** One direct (explicit, non-inherited) allow ACE of a directory DACL. */ +interface DirectAce { + sid: string + mask: number +} + +/** Convert one SID string to a LocalAlloc'd SID pointer (caller frees). */ +function sidFromString(api: Win32Bindings, sid: string): NativePtr { + const slot = allocPtrSlot() + if (api.convertStringSidToSidW(sid, slot) === 0) throw new Error(`ConvertStringSidToSidW failed for ${sid}`) + const ptr = decodePtr(slot) + if (ptr === null) throw new Error(`ConvertStringSidToSidW returned null for ${sid}`) + return ptr +} + +/** Stringify a decoded SID layout (identifierAuthority bytes 2..5 are the big-endian value). */ +function sidString(sid: SidLayout): string { + const authority = ((sid.identifierAuthority[2] ?? 0) << 24) + | ((sid.identifierAuthority[3] ?? 0) << 16) + | ((sid.identifierAuthority[4] ?? 0) << 8) + | (sid.identifierAuthority[5] ?? 0) + const subs = sid.subAuthority.slice(0, sid.subAuthorityCount).join('-') + return `S-${sid.revision}-${authority}${sid.subAuthorityCount > 0 ? `-${subs}` : ''}` +} + +/** + * Read the directory's explicit allow ACEs (inherited ACEs excluded): each + * ACE header is AceType@0, AceFlags@1, AceSize@2 (winnt.h lines ~3477-3480); + * ACCESS_ALLOWED_ACE stores Mask@4 and the inline SID@8. The ACL pointer sits + * inside the descriptor allocation — only the descriptor is LocalFree'd. + */ +function readDirectAces(api: Win32Bindings, path: string): DirectAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW failed (${readResult}) for ${path}`) + const acl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: DirectAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const flags = koffi.decode(acl, offset + 1, 'uint8') as number + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + if ((flags & abi.INHERITED_ACE) === 0) { + aces.push({ sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), mask: koffi.decode(acl, offset + 4, 'uint32') as number }) + } + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + +describe.skipIf(!isWin32)('ACL editing', () => { + const scratchDirs: string[] = [] + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-')) + scratchDirs.push(dir) + return dir + } + + it('grantWrite merges into the current DACL: an explicit Users ACE survives grant+revoke', async () => { + const api = await win32() + const dir = scratch() + const usersSid = sidFromString(api, 'S-1-5-32-545') + const orphanSid = sidFromString(api, 'S-1-4-4242-1') + try { + // Install one explicit ACE (Users + benign read mask) with the + // package's own bindings, exactly like a pre-existing explicit DACL + // entry another sandbox instance or administrator added. + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(usersSid, abi.GRANT_ACCESS, FILE_READ_DATA), null, newAclSlot) + expect(mergeResult, `SetEntriesInAclW setup (${mergeResult})`).toBe(abi.ERROR_SUCCESS) + const newAcl = decodePtr(newAclSlot) + expect(newAcl).not.toBeNull() + const applyResult = api.setNamedSecurityInfoW( + dir, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, null, null, newAcl, null, + ) + const freed = newAcl === null ? null : api.localFree(newAcl) + expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) + expect(isNullPtr(freed)).toBe(true) + + grantWrite(api, dir, orphanSid) + revokeWrite(api, dir, orphanSid) + + const aces = readDirectAces(api, dir) + expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved + expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed + } finally { + if (!isNullPtr(usersSid)) api.localFree(usersSid) + if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + } + }) + + it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + const api = await win32() + const dir = scratch() + const orphanSid = sidFromString(api, 'S-1-4-4242-2') + const apply = vi.spyOn(api, 'setNamedSecurityInfoW') + try { + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + // The exact ACE now stands (the per-session grant surviving from a + // previous server lifetime): the second grant is a DACL read only. + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + const aces = readDirectAces(api, dir) + expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + revokeWrite(api, dir, orphanSid) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) + } finally { + apply.mockRestore() + if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + } + }) + + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => { + const api = await win32() + const dir = scratch() + const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) + await sandboxA.init() + await sandboxB.init() + // Workspace ACEs are STANDING: dispose frees the instance's SID + // allocations but deliberately leaves the ACEs — they are the reuse + // cache the next provision's exact-ACE skip consumes. + sandboxA.dispose() + sandboxB.dispose() + const aces = readDirectAces(api, dir) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true) + }) + + it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => { + const api = await win32() + const workspaceDir = scratch() + const tempDir = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + await sandbox.init() + sandbox.dispose() + const workspaceAces = readDirectAces(api, workspaceDir) + expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) + const tempAces = readDirectAces(api, tempDir) + expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false) + }) + + it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { + const dir = scratch() + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) + .toThrow(/requires a write SID/) + expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write')) + .toThrow(/requires the write SID/) + }) + + it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { + const api = await win32() + const dir = scratch() + const lockPath = lockFilePath(api, dir) + const open = (): NativePtr => api.createFileW( + lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null, + ) + const first = open() + const second = open() + expect(isInvalidHandle(first)).toBe(false) + expect(isInvalidHandle(second)).toBe(false) + try { + expect(api.lockFileEx(first, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(0) + expect(api.getLastError()).toBe(abi.ERROR_LOCK_VIOLATION) + expect(api.unlockFileEx(first, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.unlockFileEx(second, 0, 1, 0, allocOverlapped())).toBe(1) + } finally { + api.closeHandle(first) + api.closeHandle(second) + rmSync(lockPath, { force: true }) + } + }) + + it('withPathLock serializes the action and releases the lock even when the action throws', async () => { + const api = await win32() + const dir = scratch() + const lockPath = lockFilePath(api, dir) + let attempts = 0 + expect(() => withPathLock(api, dir, () => { + attempts++ + throw new Error('action failure') + })).toThrow('action failure') + expect(attempts).toBe(1) + // The lock was released: a fresh immediate lock succeeds. + const handle = api.createFileW( + lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null, + ) + expect(isInvalidHandle(handle)).toBe(false) + try { + expect(api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.unlockFileEx(handle, 0, 1, 0, allocOverlapped())).toBe(1) + } finally { + api.closeHandle(handle) + rmSync(lockPath, { force: true }) + } + }) + + it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => { + const api = await win32() + const dir = scratch() + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5', mode: 'workspace-write' }) + try { + await sandbox.init() + const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5') + expect(grant).toBeDefined() + const mask = grant?.mask ?? 0 + expect(mask).toBe(abi.GRANT_MASK) + expect(mask & abi.DELETE).toBe(abi.DELETE) + expect(mask & abi.FILE_DELETE_CHILD).toBe(abi.FILE_DELETE_CHILD) + expect(mask & 0x00040000).toBe(0) // WRITE_DAC must never be granted + expect(mask & 0x00080000).toBe(0) // WRITE_OWNER must never be granted + } finally { + sandbox.dispose() + } + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts new file mode 100644 index 0000000000..0c1d7f42b3 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -0,0 +1,138 @@ +/** + * Failure-path unit tests with minimal stub binding tables: the spawn + * helpers must close every handle they created before throwing, and + * getTempPath must refuse to decode a buffer GetTempPathW never wrote. + * Pure stubs — no real Win32 calls, so these run on every platform. + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ +function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } { + const closed: bigint[] = [] + let next = 1n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, next++) + koffi.encode(writeSlot, PVOID, next++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(() => 0), + getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +/** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */ +function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } { + const closed: bigint[] = [] + let std = 50n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createJobObjectW: vi.fn(() => 100n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn(() => std++), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0xFFFFFFFF), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +describe('spawn failure paths close their handles', () => { + // A dummy token value; the stubbed spawn never reads it. + const token = 1n as NativePtr + + it('spawnSandboxed closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => { + const { api, closed, closeHandle } = pipeFailureApi() + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateProcessAsUserW') + expect((caught as Win32Error).win32Code).toBe(5) + expect(closeHandle).toHaveBeenCalledTimes(6) + expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n]) + }) + + it('spawnSandboxedInherited closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { + const { api, closed, closeHandle } = resumeFailureApi() + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('ResumeThread') + expect((caught as Win32Error).win32Code).toBe(5) + // thread, process, job — closing the job triggers kill-on-close so the + // suspended child dies instead of hanging until this process exits. + expect(closeHandle).toHaveBeenCalledTimes(3) + expect(closed).toEqual([201n, 200n, 100n]) + }) + + it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => { + // The child is created suspended and is NOT in the kill-on-close job when + // the assignment fails: closing the job cannot kill it, so the failure + // branch must TerminateProcess first or every failure strands a hanging + // orphan forever. + const { api: baseApi, closeHandle } = resumeFailureApi() + type JobFailureApi = Win32Bindings & { + assignProcessToJobObject: ReturnType<typeof vi.fn> + terminateProcess: ReturnType<typeof vi.fn> + } + const api = baseApi as JobFailureApi + api.assignProcessToJobObject = vi.fn(() => 0) + api.terminateProcess = vi.fn(() => 1) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('AssignProcessToJobObject') + expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1) + // thread, process, job — and the child is already dead before they close. + expect(closeHandle).toHaveBeenCalledTimes(3) + }) +}) + +describe('getTempPath buffer defense', () => { + it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => { + const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer + expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts new file mode 100644 index 0000000000..fe803025b9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -0,0 +1,100 @@ +/** + * AclWriteGrant failure-path tests with stub binding tables (the + * failure-paths.spec.ts pattern): create fails closed on SID-parse failure, + * dispose aggregates revocation and SID-free failures into an + * AggregateError. Pure stubs — no real Win32 calls, so these run on every + * platform; the real-FFI round-trip lives in grant.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import koffi from 'koffi' + +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { AclWriteGrant } from '../src/index.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */ +function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { + const state = { failReads: false } + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/` + buffer.write(temp, 'utf16le') + return temp.length + }), + createFileW: vi.fn(() => 7n), + lockFileEx: vi.fn(() => 1), + unlockFileEx: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails + koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(descriptor, PVOID, 0n) + return 0 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, 9n) + return 0 + }), + setNamedSecurityInfoW: vi.fn(() => 0), + localFree: vi.fn(() => 0n), + getLastError: vi.fn(() => 2), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, failReads: () => { state.failReads = true } } +} + +describe('AclWriteGrant failure paths', () => { + it('create fails closed: a SID parse failure throws before anything is granted', () => { + const api = { + convertStringSidToSidW: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-abc-1', api)).toThrow(/ConvertStringSidToSidW/) + }) + + it('create fails closed: a null SID pointer is rejected', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 0n) + return 1 + }), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/null SID/) + }) + + it('dispose aggregates a failing revocation into an AggregateError (best-effort cleanup)', () => { + const { api, failReads } = grantThenFailApi() + const grant = AclWriteGrant.create('S-1-4-42-42', api) + grant.add('C:\\granted') + expect(grant.paths).toEqual(['C:\\granted']) + failReads() + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) + + it('dispose aggregates a failing SID free into an AggregateError', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const grant = AclWriteGrant.create('S-1-4-42-42', api) + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts new file mode 100644 index 0000000000..43810249ab --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -0,0 +1,77 @@ +/** + * AclWriteGrant tests: the server-side grant materialization — SID parsing + * fail-closed, ACE add/dispose round-trip against the REAL directory DACL + * (observed through icacls, the operator's own tool), the recorded path + * order, and the standing/revocable lifecycle split (workspace ACEs outlive + * dispose as the reuse cache; temp ACEs revoke). Win32-only, like the other + * real-FFI suites. + */ + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { AclWriteGrant } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +/** The directory DACL as icacls renders it (the operator-visible form). */ +function icaclsText(path: string): string { + const result = spawnSync('icacls', [path], { encoding: 'utf8' }) + expect(result.status, `icacls failed: ${result.stderr}`).toBe(0) + return result.stdout +} + +describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { + const scratchDirs: string[] = [] + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-grant-')) + scratchDirs.push(dir) + return dir + } + + it('create parses the SID fail-closed: a malformed SID throws before anything is granted', () => { + expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u) + }) + + it('add materializes the ACE (idempotently) and reports grant order; dispose revokes revocable paths and keeps standing paths standing', () => { + const dir = scratch() + const standingDir = scratch() + const grant = AclWriteGrant.create('S-1-4-9000-77') + grant.add(dir) // revocable: the session-temp lifecycle + grant.add(standingDir, true) // standing: the workspace reuse cache + expect(grant.paths).toEqual([standingDir, dir]) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') + // A second add over the standing exact ACE is a DACL-read no-op: the + // grant stays exactly one ACE (the reuse across sessions/restarts). + grant.add(dir) + grant.add(standingDir, true) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') + grant.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') + }) + + it('two grants with different SIDs coexist and revoke independently', () => { + const dir = scratch() + const grantA = AclWriteGrant.create('S-1-4-9000-78') + const grantB = AclWriteGrant.create('S-1-4-9000-79') + grantA.add(dir) + grantB.add(dir) + expect(icaclsText(dir)).toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantA.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantB.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-79') + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts new file mode 100644 index 0000000000..0825695438 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -0,0 +1,97 @@ +/** + * End-to-end probe of the ACL write-restriction sandbox, using the same + * probes as the POC verification harness: the confined child must be able to + * write into the granted target and temp directories, must be DENIED writing + * anywhere else, and (documented boundary) may still READ outside — the + * WRITE_RESTRICTED token intersects write accesses only. + * + * The escape target sits in its own scratch dir under the system temp + * directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never + * defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the + * whole real temp tree) and the writable dir is a separate mkdtemp directory + * that contains neither sibling. Nothing under the user profile is touched. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { AclSandbox } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let sandbox: AclSandbox + + beforeAll(async () => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + // tempDir is passed explicitly: GetTempPathW reads the native environment + // block, which host runtimes (vitest worker pools) may not keep in sync + // with process.env — and a real-temp grant would inherit over every + // temp subdirectory, including this test's scratch dir. + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + await sandbox.init() + }) + + afterAll(() => { + sandbox.dispose() + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('allows writes only in granted directories and denies the escape write', async () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const child = sandbox.spawn({ + command: 'pwsh', + args: ['/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe], + cwd: writableDir, + }) + const result = await child.wait() + const output = result.stdout.toString('utf8') + result.stderr.toString('utf8') + + expect(result.exitCode, `child output:\n${output}`).toBe(0) + expect(output, `child output:\n${output}`).toContain('TARGET-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('TEMP-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('ESCAPE-WRITE: DENIED') + // Documented boundary: WRITE_RESTRICTED intersects write accesses only, + // so reads outside the allowlist still succeed. + expect(output, `child output:\n${output}`).toContain('SECRET-READ: OK') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { + // A malformed SID makes ConvertStringSidToSidW fail; init must throw + // before any grant is applied and never spawn unrestricted. + const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts new file mode 100644 index 0000000000..2557b1f43b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -0,0 +1,58 @@ +/** + * The win32 chain's argv contract, denial dialect, and runner-failure rules, + * exercised through the REAL LocalSandboxProvider.confine() with an injected + * platform and runner argv prefix. Platform-independent assertions: they run + * in every CI lane (Windows included, where sandbox-local's own POSIX-only + * suites are excluded) — the end-to-end runner behavior lives in + * runner.spec.ts on win32 hosts. + */ + +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' + +const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } +const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + +async function setup(internals: LocalSandboxProvider['internals']) { + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = internals + return sandbox +} + +describe('windows-acl win32 chain (LocalSandboxProvider)', () => { + it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const sandbox = await setup({ + platform: 'win32', + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + probeWindowsAcl, + }) + const confined = sandbox.confine(['pwsh', '/Command', 'x'], WW) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) + // A sole candidate is selected unprobed. + expect(probeWindowsAcl).not.toHaveBeenCalled() + }) + + it('read-only: same runner and contract, read-only mode flag', async () => { + const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts new file mode 100644 index 0000000000..5af00fb9cb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts @@ -0,0 +1,88 @@ +/** + * quoteArg unit tests plus a round-trip through the REAL CommandLineToArgvW + * parser (shell32.dll, shellapi.h line ~867: + * `LPWSTR *CommandLineToArgvW(LPCWSTR lpCmdLine, int *pNumArgs)`) on win32. + * + * CommandLineToArgvW applies the documented backslash rule (2n backslashes + * before a quote produce n backslashes and toggle quoting; 2n+1 produce n + * backslashes and a literal quote) to every token EXCEPT the first — the + * first token is parsed with backslashes literal and quotes toggling + * (verified empirically on this machine, Windows 11 build 26200). The + * round-trip therefore prepends a plain program token, exactly like + * buildCommandLine's real callers do, so the arguments under test land on + * the rule-applying tokens. + * + * Reading argv from CommandLineToArgvW: koffi cannot decode the returned + * LPWSTR* contents directly (the pointed-to strings are not koffi-registered + * references), so each string is copied with lstrcpynW (winbase.h line + * ~1500) into a Node Buffer and read as UTF-16LE; lengths come from + * lstrlenW (winbase.h line ~1506); the argv block is freed with LocalFree + * (winbase.h line ~1127) — CommandLineToArgvW's documented contract. + */ + +import { describe, expect, it } from 'vitest' + +import { buildCommandLine, quoteArg } from '../src/spawn.ts' + +const isWin32 = process.platform === 'win32' + +/** + * Table cases: input argv entry → the exact command-line fragment quoteArg + * must produce. Trailing-backslash inputs are the regression: the closing + * quote must be preceded by DOUBLED backslashes, or the parser reads them as + * escaping the closing quote. + */ +const cases: Array<[input: string, quoted: string]> = [ + ['', '""'], + ['a', 'a'], + ['a b', '"a b"'], + ['a"b', '"a\\"b"'], + ['a\\b', 'a\\b'], + ['a b\\', '"a b\\\\"'], + ['a b\\\\', '"a b\\\\\\\\"'], + ['a b\\\\\\', '"a b\\\\\\\\\\\\"'], + ['a\\\\"b', '"a\\\\\\\\\\"b"'], +] + +describe('quoteArg', () => { + it.each(cases)('quotes %j as %j', (input, quoted) => { + expect(quoteArg(input)).toBe(quoted) + }) +}) + +describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => { + it('parses quoteArg+join back to the exact original argv', async () => { + const { default: koffi } = await import('koffi') + const PVOID = koffi.pointer('void') + const shell32 = koffi.load('shell32.dll') + const kernel32 = koffi.load('kernel32.dll') + const commandLineToArgvW = shell32.func('__stdcall', 'CommandLineToArgvW', PVOID, ['str16', koffi.pointer('int')]) + const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int']) + const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID]) + const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID]) + + const parse = (commandLine: string): string[] => { + const countSlot = koffi.alloc('int', 1) as unknown + const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown + try { + if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL') + const count = koffi.decode(countSlot, 0, 'int') as number + const table = Buffer.from(koffi.view(argvBlock, count * 8)) + const parsed: string[] = [] + for (let index = 0; index < count; index++) { + const stringAddress = table.readBigUInt64LE(index * 8) + const copied = Buffer.alloc(2048) + lstrcpynW(copied, stringAddress, copied.length / 2) + const length = lstrlenW(copied) as number + parsed.push(copied.subarray(0, length * 2).toString('utf16le')) + } + return parsed + } finally { + localFree(argvBlock) + } + } + + const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a b\\\\\\', 'a\\\\"b'] + expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts new file mode 100644 index 0000000000..30229d6206 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -0,0 +1,294 @@ +/** + * End-to-end runner tests: spawn the REAL runner entry through tsx (exactly + * the argv shape dsh-sandbox-local's confine() builds), with piped stdio + * inherited through the runner into the confined child — the same chain a + * production confined execution walks. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { AclWriteGrant } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' +const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) + +// Functional probe, not where.exe: spawnSync never throws on a missing +// binary (status null) and where.exe exits 1 without pwsh — only an actual +// pwsh invocation's exit status is truth. +function pwshAvailable(): boolean { + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +} + +function runRunner(args: string[], timeoutMs = 30_000) { + return spawnSync(process.execPath, ['--import', 'tsx/esm', runnerEntry, ...args], { + timeout: timeoutMs, + encoding: 'utf8', + }) +} + +describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + // The ambient-writable probe target: a subdirectory of C:\Users\Public. + // INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public + // tree's INTERACTIVE grant must NOT satisfy the write check — the ambient + // boundary the dual-list design closes (bot-reported blind spot). The + // Public tree may be unavailable or unwritable for the test user on some + // hosts; the probe test skips itself when the directory cannot be created. + let publicProbeDir: string | undefined + + beforeAll(() => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + try { + publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-')) + } catch { + publicProbeDir = undefined + } + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + if (publicProbeDir !== undefined) rmSync(publicProbeDir, { recursive: true, force: true }) + }) + + it('workspace-write: the confined child writes granted directories only', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + // The restricted token puts pwsh into ConstrainedLanguage in BOTH modes + // (documented Known Limitation) — pinned here so a token change that + // silently restores FullLanguage is caught. + '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // Authenticated Users is absent from BOTH lists: the WMI namespace + // security check fails (0x80041003) — CIM is unavailable under every + // confined mode (the documented contract; the C:\-root tree-creation + // escape is closed in both as the other side of the trade). + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') + expect(result.stdout).toContain('TARGET-WRITE: OK') + expect(result.stdout).toContain('TEMP-WRITE: OK') + expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: DENIED') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', + `try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + // The NUL device is a securable object: strict zero grants deny it too. + 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', + // PowerShell's $null redirection discards without opening NUL — must keep working. + 'echo hi > $null;\'DOLLAR-NULL: OK\';', + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // BOTH lists drop Authenticated Users: the WMI namespace security + // check fails (0x80041003) — the documented CIM boundary of every + // confined mode, the price of the zero ambient-write surface. + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') + expect(result.stdout).toContain('TARGET-WRITE: DENIED') + expect(result.stdout).toContain('TEMP-WRITE: DENIED') + expect(result.stdout).toContain('NUL-WRITE: DENIED') + expect(result.stdout).toContain('DOLLAR-NULL: OK') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: DENIED') + expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) + }, 30_000) + + it('workspace-write: Remove-Item and Rename-Item succeed in the granted workspace (DELETE + FILE_DELETE_CHILD)', () => { + // Deleting a file and renaming a directory both hit the second access + // check on the workspace itself: the grant must carry DELETE (on the + // object) and FILE_DELETE_CHILD (on its parent). + const victimFile = join(writableDir, 'delete-me.txt') + writeFileSync(victimFile, 'remove me') + const victimDir = join(writableDir, 'rename-me') + mkdirSync(victimDir) + const renamedDir = join(writableDir, 'renamed-by-child') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Remove-Item -LiteralPath '${victimFile}' -ErrorAction Stop;'DELETE-FILE: OK'}catch{'DELETE-FILE: DENIED'};`, + `try{Rename-Item -LiteralPath '${victimDir}' -NewName 'renamed-by-child' -ErrorAction Stop;'RENAME-DIR: OK'}catch{'RENAME-DIR: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('DELETE-FILE: OK') + expect(result.stdout).toContain('RENAME-DIR: OK') + expect(existsSync(victimFile)).toBe(false) + expect(existsSync(renamedDir)).toBe(true) + }, 30_000) + + it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => { + const writeSid = 'S-1-4-9000-99' + const privateTemp = join(isolatedTemp, 'private-subdir') + mkdirSync(privateTemp) + const grant = AclWriteGrant.create(writeSid) + grant.add(privateTemp) + try { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, + `try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`, + "'TEMP-ENV: ' + $env:TEMP;", + "'TMP-ENV: ' + $env:TMP", + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + // The runner granted nothing (only the caller's private-temp grant + // stands): the workspace write is denied, the private temp write lands, + // and the child's TMP/TEMP point at the private subdirectory. + expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED') + expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK') + expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`) + expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`) + expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false) + expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true) + } finally { + grant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) + } + }, 30_000) + + it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => { + // Two-layer pin of the grandchild-spawn boundary: + // - the token default DACL carries a restricting-SID ACE (set in init), + // so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL + // consumer) works and inherited/ignored stdio spawns succeed; + // - libuv's pipe-stdio uses NAMED pipes, whose default security + // descriptor is the Win32 layer's user-mode default SD template + // (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS + // read-only) — NOT the token default DACL, which is what the kernel + // applies to a raw SD-null create — so the client-end open requests + // write access no restricting SID is + // granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the + // POC-documented "no output redirection" boundary of WRITE_RESTRICTED + // tokens; piped capture cannot work and is pinned as DENIED. + const probe = [ + "const { spawnSync } = require('child_process');", + "const t = (name, opts) => { const s = spawnSync(process.execPath, ['-e', '1'], { encoding: 'utf8', ...opts }); console.log(name + ':' + (s.status === 0 ? 'OK' : 'DENIED')); };", + "t('inherit', { stdio: 'inherit' });", + "t('ignore', { stdio: 'ignore' });", + "t('pipe', { stdio: 'pipe' });", + ].join('') + for (const mode of ['workspace-write', 'read-only'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'node', '-e', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('inherit:OK') + expect(result.stdout, `mode: ${mode}`).toContain('ignore:OK') + expect(result.stdout, `mode: ${mode}`).toContain('pipe:DENIED') + } + }, 30_000) + + it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => { + // The reported defect: a session that materialized its grant in + // workspace-write keeps the ACE standing for the server lifetime. After + // switching to read-only, the restricted token's read-only list must carry NO + // orphan SID — the standing ACE stays but the pass-2 check cannot use + // it, so the workspace write is denied (previously it LEAKED). The + // switch back reuses the SAME standing ACE: the re-upgrade write lands + // without any re-grant. + const writeSid = 'S-1-4-9001-7' + const grant = AclWriteGrant.create(writeSid) + grant.add(writableDir) + try { + const downgradeProbe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`, + ].join('') + const downgraded = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe, + ]) + expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0) + expect(downgraded.stdout).toContain('DOWNGRADE-WRITE: DENIED') + expect(existsSync(join(writableDir, 'downgraded.txt'))).toBe(false) + + const reupgradeProbe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`, + ].join('') + const reupgraded = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe, + ]) + expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0) + expect(reupgraded.stdout).toContain('REUPGRADE-WRITE: OK') + expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true) + } finally { + grant.dispose() + } + }, 30_000) + + it('ambient-writable escape regression: a C:\\Users\\Public subdirectory is denied under BOTH modes (INTERACTIVE absent from both lists)', (ctx) => { + // The Public tree grants write to INTERACTIVE; the D1-D6 matrix pinned + // that removing INTERACTIVE from the restricting lists closes the escape. + // The committed suites never probed it — this pins the ambient boundary + // end to end with the real restricted token. + if (publicProbeDir === undefined) { + ctx.skip() // Public unavailable/unwritable on this host + return + } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${publicProbeDir}\\public-escaped.txt' -Value ok -ErrorAction Stop;'PUBLIC-WRITE: OK (ESCAPE!)'}catch{'PUBLIC-WRITE: DENIED'}`, + ].join('') + for (const mode of ['read-only', 'workspace-write'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('PUBLIC-WRITE: DENIED') + expect(existsSync(join(publicProbeDir, 'public-escaped.txt')), `mode: ${mode}`).toBe(false) + } + }, 30_000) + + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { + const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) + expect(result.status).toBe(127) + expect(result.stderr).toContain('windows-acl-run: ') + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts new file mode 100644 index 0000000000..4d24c6f8fb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -0,0 +1,30 @@ +/** + * workspaceWriteSid tests: the per-workspace write identity is deterministic + * (the same canonical path always derives the same SID — the property the + * cross-session grant reuse rests on), orphan-shaped, distinct across + * workspaces, and byte-sensitive (the canonical path is the caller's + * contract; an alias spelling derives a second identity, self-healing at + * the cost of one extra tree propagation). + */ + +import { describe, expect, it } from 'vitest' + +import { workspaceWriteSid } from '../src/index.ts' + +describe('workspaceWriteSid', () => { + it('derives a stable orphan-shaped SID per workspace path', () => { + const first = workspaceWriteSid('C:\\Users\\agent\\repo') + const second = workspaceWriteSid('C:\\Users\\agent\\repo') + expect(first).toBe(second) + expect(first).toMatch(/^S-1-4-\d+-\d+$/u) + }) + + it('derives distinct identities for distinct workspaces', () => { + expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b')) + }) + + it('is byte-sensitive: the canonical path is the caller\'s contract (an alias spelling derives a second identity)', () => { + expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo')) + expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json new file mode 100644 index 0000000000..e882ed2d72 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sandbox/sandbox-windows-acl/tsdown.config.ts b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts new file mode 100644 index 0000000000..7de4ede1e1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +// The confinement runner builds as its own entry (path-loaded by +// dsh-sandbox-local's win32 chain), inlining the sandbox primitives while +// koffi stays an external native require — the same shape as +// directory-picker-native's worker entry. +export default defineConfig({ + entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', runner: 'lib/types/runner.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp new file mode 100644 index 0000000000..a74afe9d80 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -0,0 +1,195 @@ +// ABI probe: prints sizeof/offsetof/enum values from the actual MinGW Windows +// headers on this machine. These numbers are the source of truth for the +// koffi FFI definitions in the Node.js port. +#include <Windows.h> +#include <sddl.h> +#include <AclAPI.h> +#include <cstdio> +#include <cstddef> + +#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr)) + +int wmain() +{ + P(sizeof(void*)); + P(sizeof(HANDLE)); + P(sizeof(DWORD)); + P(sizeof(WORD)); + P(sizeof(BOOL)); + + P(sizeof(STARTUPINFOW)); + P(offsetof(STARTUPINFOW, cb)); + P(offsetof(STARTUPINFOW, lpReserved)); + P(offsetof(STARTUPINFOW, lpDesktop)); + P(offsetof(STARTUPINFOW, lpTitle)); + P(offsetof(STARTUPINFOW, dwX)); + P(offsetof(STARTUPINFOW, dwY)); + P(offsetof(STARTUPINFOW, dwXSize)); + P(offsetof(STARTUPINFOW, dwYSize)); + P(offsetof(STARTUPINFOW, dwXCountChars)); + P(offsetof(STARTUPINFOW, dwYCountChars)); + P(offsetof(STARTUPINFOW, dwFillAttribute)); + P(offsetof(STARTUPINFOW, dwFlags)); + P(offsetof(STARTUPINFOW, wShowWindow)); + P(offsetof(STARTUPINFOW, cbReserved2)); + P(offsetof(STARTUPINFOW, lpReserved2)); + P(offsetof(STARTUPINFOW, hStdInput)); + P(offsetof(STARTUPINFOW, hStdOutput)); + P(offsetof(STARTUPINFOW, hStdError)); + + P(sizeof(PROCESS_INFORMATION)); + P(offsetof(PROCESS_INFORMATION, hProcess)); + P(offsetof(PROCESS_INFORMATION, hThread)); + P(offsetof(PROCESS_INFORMATION, dwProcessId)); + P(offsetof(PROCESS_INFORMATION, dwThreadId)); + + P(sizeof(SECURITY_ATTRIBUTES)); + P(offsetof(SECURITY_ATTRIBUTES, nLength)); + P(offsetof(SECURITY_ATTRIBUTES, lpSecurityDescriptor)); + P(offsetof(SECURITY_ATTRIBUTES, bInheritHandle)); + + P(sizeof(TRUSTEE_W)); + P(offsetof(TRUSTEE_W, pMultipleTrustee)); + P(offsetof(TRUSTEE_W, MultipleTrusteeOperation)); + P(offsetof(TRUSTEE_W, TrusteeForm)); + P(offsetof(TRUSTEE_W, TrusteeType)); + P(offsetof(TRUSTEE_W, ptstrName)); + + P(sizeof(EXPLICIT_ACCESS_W)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessPermissions)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessMode)); + P(offsetof(EXPLICIT_ACCESS_W, grfInheritance)); + P(offsetof(EXPLICIT_ACCESS_W, Trustee)); + + P(sizeof(SID_AND_ATTRIBUTES)); + P(offsetof(SID_AND_ATTRIBUTES, Sid)); + P(offsetof(SID_AND_ATTRIBUTES, Attributes)); + + P(sizeof(TOKEN_GROUPS)); + P(offsetof(TOKEN_GROUPS, GroupCount)); + P(offsetof(TOKEN_GROUPS, Groups)); + + P(sizeof(TOKEN_MANDATORY_LABEL)); + + P(sizeof(SID)); + P(SECURITY_MAX_SID_SIZE); + P(SID_MAX_SUB_AUTHORITIES); + P(SID_REVISION); + + P(TOKEN_ASSIGN_PRIMARY); + P(TOKEN_DUPLICATE); + P(TOKEN_QUERY); + P(TOKEN_ADJUST_DEFAULT); + + P(SE_GROUP_LOGON_ID); + P(SE_GROUP_INTEGRITY); + P(SE_GROUP_INTEGRITY_ENABLED); + + P(FILE_GENERIC_WRITE); + P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); + P(STANDARD_RIGHTS_WRITE); + P(DELETE); + P(FILE_DELETE_CHILD); + P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE)); + + P(FILE_SHARE_READ); + P(FILE_SHARE_WRITE); + P(FILE_SHARE_DELETE); + P(GENERIC_READ); + P(GENERIC_WRITE); + P(OPEN_ALWAYS); + P(LOCKFILE_EXCLUSIVE_LOCK); + P(LOCKFILE_FAIL_IMMEDIATELY); + P(ERROR_LOCK_VIOLATION); + P(INHERITED_ACE); + + P(DISABLE_MAX_PRIVILEGE); + P(SANDBOX_INERT); + P(LUA_TOKEN); + P(WRITE_RESTRICTED); + + P((int)WinWorldSid); + P((int)WinLocalLogonSid); + P((int)WinConsoleLogonSid); + + P((int)TokenUser); + P((int)TokenGroups); + P((int)TokenIntegrityLevel); + + P((int)SE_FILE_OBJECT); + P(DACL_SECURITY_INFORMATION); + + P((int)TRUSTEE_IS_UNKNOWN); + P((int)TRUSTEE_IS_SID); + P((int)NOT_USED_ACCESS); + P((int)GRANT_ACCESS); + P((int)REVOKE_ACCESS); + P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(OBJECT_INHERIT_ACE); + P(CONTAINER_INHERIT_ACE); + + P(CREATE_SUSPENDED); + P(CREATE_NO_WINDOW); + P(DETACHED_PROCESS); + P(CREATE_NEW_CONSOLE); + P(STARTF_USESTDHANDLES); + P(HANDLE_FLAG_INHERIT); + P(INFINITE); + + P(LMEM_FIXED); + P(LMEM_ZEROINIT); + P(LPTR); + + P(FORMAT_MESSAGE_ALLOCATE_BUFFER); + P(FORMAT_MESSAGE_FROM_SYSTEM); + P(FORMAT_MESSAGE_IGNORE_INSERTS); + P(MAX_PATH); + + P(ERROR_SUCCESS); + P(ERROR_INSUFFICIENT_BUFFER); + P(ERROR_NO_MORE_ITEMS); + P(ERROR_INVALID_PARAMETER); + P(ERROR_INVALID_SID); + P(ERROR_NONE_MAPPED); + P(ERROR_BROKEN_PIPE); + + // Job object (runner kill-on-close hardening) + P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + P(sizeof(JOBOBJECT_BASIC_LIMIT_INFORMATION)); + P(sizeof(IO_COUNTERS)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, ProcessMemoryLimit)); + P((int)JobObjectExtendedLimitInformation); + P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + + // static assertions for the values the koffi module will hardcode + static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); + static_assert(sizeof(SECURITY_ATTRIBUTES) == 24, "SECURITY_ATTRIBUTES size"); + static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); + static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); + static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); + static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); + static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); + static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); + static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "poc grant mask"); + static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights"); + static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask"); + static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes"); + static_assert(OPEN_ALWAYS == 4, "open always"); + static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags"); + static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation"); + static_assert(INHERITED_ACE == 0x10, "inherited ace flag"); + static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); + static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); + static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); + static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); + static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); + static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); + static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); + static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); + printf("\nstatic_asserts passed\n"); + return 0; +} diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index d431dd97c4..ae67019e18 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md -README.md: 50b7eff1a287ee0bc2432a7bf409586ff879920e -README.zh.md: ff68a49f5487b5da9700698c5376199727f1cd5f +README.md: d8e2cf18e8dfc50a60e6c0f46a96e1047081736c +README.zh.md: c5ca0b100af523c5050ff1f96bdd67a853a0ea2a diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 50b7eff1a2..d8e2cf18e8 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -23,7 +23,7 @@ Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-b ##### Exact error ```markdown -sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. ``` #### Token effect diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index ff68a49f54..c5ca0b100a 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -23,7 +23,7 @@ ##### 精确错误 ```markdown -sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. ``` #### Token 影响 diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index deb703dc15..7c91afe76b 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -27,11 +27,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 75f8e9b846..5fa3cc991b 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -7,6 +7,7 @@ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' export { ESCALATION_TARGETS, @@ -40,6 +41,14 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. + */ + sessionId?: SessionId } /** @@ -124,8 +133,9 @@ export class SandboxUnavailableError extends HarnessError { super( `sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; ` + 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing ' - + 'kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement ' - + 'backend yet — or switch the consumer to danger-full-access.' + + 'kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL ' + + 'restricted-token runner can start (Windows) — otherwise switch the consumer to ' + + 'danger-full-access.' + (detail === undefined ? '' : ` Runner failure: ${detail}`), SANDBOX_UNAVAILABLE, ) diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index af4de1c016..673ee51547 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/scaffold/helper/src/features/builtin/app.ts b/packages/scaffold/helper/src/features/builtin/app.ts index 106cf07097..1695accf56 100644 --- a/packages/scaffold/helper/src/features/builtin/app.ts +++ b/packages/scaffold/helper/src/features/builtin/app.ts @@ -50,7 +50,7 @@ class AppOption extends FeatureOption { this.label = label } - /** Identify options by their unique front door, not the shared interaction service. */ + /** Identify external options by their run interface, not the shared interaction service. */ override markerConfigEntries(): readonly { id: string; name: string }[] { switch (this.id) { case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }] @@ -58,7 +58,7 @@ class AppOption extends FeatureOption { } } - /** Embed is identified by the configured loop with no external front door. */ + /** Embed is identified by the configured loop and absence of an external entry point. */ override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean { if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile) return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop') @@ -93,7 +93,7 @@ export class AppFeature extends ExclusiveOptionFeature { new AppOption('embed', 'Embedded context'), ] - /** Default to the profile's already selected front door. */ + /** Default to the profile's already selected run interface. */ override defaultOptions(profile: ProjectProfile): readonly string[] { return [profile.runInterface] } diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts index 5eb3d2bc5a..93d51c5ffe 100644 --- a/packages/scaffold/helper/src/features/feature.ts +++ b/packages/scaffold/helper/src/features/feature.ts @@ -112,7 +112,7 @@ export abstract class Feature { readonly requires: readonly FeatureId[] = [] /** Features recommended during creation. */ readonly suggests: readonly FeatureId[] = [] - /** Front doors under which this feature is meaningful. */ + /** Run interfaces under which this feature is meaningful. */ readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed'] /** @@ -141,7 +141,7 @@ export abstract class Feature { } /** - * Whether the feature may be selected for this project front door. + * Whether the feature may be selected for this project run interface. * @param profile - project context to check. * @returns whether the feature applies. */ diff --git a/packages/scaffold/helper/src/project/types.ts b/packages/scaffold/helper/src/project/types.ts index 0c66bb5ac2..72f4259e88 100644 --- a/packages/scaffold/helper/src/project/types.ts +++ b/packages/scaffold/helper/src/project/types.ts @@ -8,7 +8,7 @@ import type { PackageManager } from '../package-managers/package-manager.ts' import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' import type { FeatureId } from '../ids.ts' -/** Runtime front door selected for a generated project. */ +/** Run interface selected for a generated project. */ export type RunInterface = 'acp' | 'embed' /** Values shared by the required provider and app features. */ diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index d0f275ca4c..41acf530ba 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -209,7 +209,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') }) - it('round-trips embed app projects without a front-door Cordis config entry', async () => { + it('round-trips embed app projects without an ACP Cordis config entry', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) temporary.push(root) const creation = request([], [], 'embed') diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 503736477c..86def75601 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise<void>', - jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by a front door.\n * @returns fulfillment after the optional settings write settles.\n */', + jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by an entry point.\n * @returns fulfillment after the optional settings write settles.\n */', }, ], }, @@ -94,6 +94,48 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'agentPresets', + summary: 'Registry over the deployment\'s agent presets.', + methods: [ + { + signature: 'async list(): Promise<AgentPreset[]>', + jsDoc: '/**\n * Every preset the configured roots currently supply.\n * @returns the presets, first-root-wins per id.\n */', + }, + { + signature: 'async resolve(id?: string): Promise<AgentPreset>', + jsDoc: '/**\n * Resolve one preset by id.\n *\n * A broken preset resolves — deleting one, reading one, and reporting one\n * all need the row — and the mounting paths refuse it AFTER resolution\n * through {@link resolveMountable}.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the resolved preset.\n * @throws when no configured root supplies that id.\n */', + }, + { + signature: 'async mount(agentCtx: Context, id?: string): Promise<AgentPreset>', + jsDoc: '/**\n * Compose one agent from a preset: ensure the preset\'s standing mount, then\n * parent the agent\'s scope key to it so the mount\'s registrations and\n * listeners cover this agent.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was composed, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', + }, + { + signature: 'async read(id: string): Promise<string>', + jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */', + }, + { + signature: 'async copy(from: string, id: string, name?: string): Promise<void>', + jsDoc: '/**\n * Create a locally authored preset by copying an existing one whole.\n *\n * Copy is the only authoring write. Composition text never crosses this\n * seam: the source is named by id and its directory is copied as it stands,\n * so the copy is exactly as loadable as its source and authoring grants no\n * capability the roster did not already carry. The copy is NOT mounted to\n * validate — a source that mounts today yields a copy that mounts today.\n * @param from - the preset the copy starts from; shipped presets are the\n * primary source, so any trust is accepted.\n * @param id - the new preset\'s id, which becomes its directory name.\n * @param name - display name for the copy; absent falls back to the id.\n * @throws when the source is unknown, the id is unusable or already taken,\n * or the deployment configures no writable root.\n */', + }, + { + signature: 'async remove(id: string): Promise<void>', + jsDoc: '/**\n * Delete a locally authored preset.\n * @param id - the preset id.\n * @throws when the preset is unknown or ships with the deployment.\n */', + }, + { + signature: 'serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined', + jsDoc: '/**\n * One agent\'s instance of a service its preset mounted.\n *\n * A preset publishes services behind `isolate` realms, which are invisible\n * outside the group that declares them — including to the host. This is how a\n * caller holding the agent reads one anyway: a request that is ABOUT a\n * session but arrives from outside it, which is every browser RPC.\n *\n * Read addressing only. A host row that `inject`s a service cannot use this,\n * because injection resolves before any session exists and has no agent to\n * key by; such a service belongs on the host plane instead.\n * @param agent - the agent whose composition to look inside.\n * @param name - the service name as the preset\'s rows resolve it.\n * @returns the agent\'s instance, or undefined when its preset mounts none.\n */', + }, + { + signature: 'async recompose(agentCtx: Context, id: string): Promise<AgentPreset>', + jsDoc: '/**\n * Re-link one agent to a different preset\'s standing composition.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot\n * make. The CALLER owns that check — this method does not read session\n * history.\n *\n * The swap is a parent re-link, not an unmount: standing mounts are shared\n * and permanent, so the old composition stays for its other agents and the\n * new one is ensured BEFORE the link moves. An unknown or unusable preset\n * therefore throws with the agent exactly as it was — there is no torn-down\n * state to restore. The re-link runs through the binding this roster kept\n * from the agent\'s mount — dsh-scope\'s only re-link authority. An agent\n * that never composed one has nothing to re-link: the switch is then the\n * agent\'s first bind, exactly a mount.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable.\n */', + }, + { + signature: 'async standingKeyFor(id?: string): Promise<ScopeKey>', + jsDoc: '/**\n * The standing scope key of one preset, for a host reader with no agent.\n *\n * A cold transcript read resolves tool presenters against the composition\n * the session recorded, and the standing mount makes that possible without\n * resuming anything: ensuring the mount composes plugins but starts no\n * agent, no session, and no turn.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the standing scope key readers pass as a registry view scope.\n * @throws when the preset is unknown or its composition is unusable.\n */', + }, + ], + }, { key: 'agents', summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.', @@ -886,27 +928,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'skills', - summary: 'Registry of skill providers.', + summary: 'Layered registry of skill providers, the host+per-scope shape the tools registry established.', methods: [ { signature: 'registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void', - jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', + jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin\n * apply, into the calling context\'s layer: a scoped context (an agent\n * preset\'s standing mount) registers for that scope alone, an unscoped\n * context registers globally. Duplicate names within one layer and reserved\n * names throw; remote initialization belongs in `list()`. Fiber disposal\n * unregisters the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', }, { signature: 'register(skill: SkillRegistration): () => void', - jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', + jsDoc: '/**\n * Register a borrowed readonly runtime skill into the calling context\'s\n * layer. Project entries outrank runtime entries, which outrank user\n * entries, within one layer. Same-name runtime entries in one layer are\n * first-wins; a duplicate logs a warning and receives a no-op disposer so\n * it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', }, { - signature: 'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>', - jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */', + signature: 'async list(options: SkillViewOptions = {}): Promise<SkillSummary[]>', + jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - view options; `scope` selects the viewing agent\'s layers, `cwd` selects project roots, and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */', }, { - signature: 'async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot>', - jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', + signature: 'async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot>', + jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - view options; `scope` selects the viewing agent\'s layers, `cwd` selects project roots, and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', }, { - signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>', - jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */', + signature: 'async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined>', + jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - view options; `scope` selects the viewing agent\'s layers,\n * `cwd` selects workspace-sensitive skills, and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */', }, ], }, @@ -1142,6 +1184,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'tools', summary: 'Tool registry and execution pipeline.', methods: [ + { + signature: 'presentAs(mode: ToolPresentationMode): () => void', + jsDoc: '/**\n * Present this agent\'s tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent\'s model sees.\n * @returns the exact disposer that restores the deployment default.\n */', + }, { signature: 'register(definition: ToolDefinition): () => void', jsDoc: '/**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */', @@ -1667,6 +1713,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', }, + { + name: 'AgentPreset', + declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', + }, { name: 'AgentSetup', declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void;', @@ -1913,7 +1963,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -1925,7 +1975,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', + 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 readonly agentPreset?: string;\n };\n}', }, { name: 'CredentialInfo', @@ -2287,6 +2337,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, + { + name: 'PresetTrust', + declaration: 'export type PresetTrust = \'system\' | \'user\';', + }, { name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: number) => void;', @@ -2473,7 +2527,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: SessionId;\n}', }, { name: 'SandboxMode', @@ -2593,7 +2647,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', }, { name: 'SessionId', @@ -2811,6 +2865,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillSummary', declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', }, + { + name: 'SkillViewOptions', + declaration: 'export interface SkillViewOptions extends SkillLookupOptions {\n readonly scope?: ScopeKey | undefined;\n}', + }, { name: 'SpillLocator', declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;', @@ -3111,6 +3169,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolOutputDefinition', declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}', }, + { + name: 'ToolPresentationMode', + declaration: 'export type ToolPresentationMode = \'native\' | \'code\' | \'both\';', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}', diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts index 3c99c7a770..6b3e20a82c 100644 --- a/packages/self-modification/tool-cordis/src/sandbox.ts +++ b/packages/self-modification/tool-cordis/src/sandbox.ts @@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts' * A write-through console for one sandbox, tagging every line with the mount * id. Write-through (host stdout/stderr), NOT buffered into the tool result: * a mounted listener fires long after the mount call returned, and its output - * must land somewhere the user can see — for a terminal front door, the host terminal. + * must land somewhere the user can see — for a terminal entry point, the host terminal. */ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> { const tag = `[cordis:${id}]` diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index ef3a920878..dc67665f77 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -162,6 +162,7 @@ interface SessionHeaderRow { parent_session: string | null seed_length: number | null delegation_depth: number | null + agent_preset: string | null } interface SearchRow extends SessionHeaderRow { @@ -552,16 +553,10 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO persisted_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, revision, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - entry.header.id, - entry.header.version, - entry.header.createdAt, - entry.header.cwd ?? null, - entry.header.parentSession ?? null, - entry.header.seedLength ?? null, - entry.header.delegationDepth ?? null, + ...headerBindings(entry.header), revision, generation, ) @@ -588,16 +583,10 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() db.prepare(` INSERT INTO temp.live_sessions - (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - entry.header.id, - entry.header.version, - entry.header.createdAt, - entry.header.cwd ?? null, - entry.header.parentSession ?? null, - entry.header.seedLength ?? null, - entry.header.delegationDepth ?? null, + ...headerBindings(entry.header), entry.fingerprint, persisted ? 1 : 0, generation, @@ -692,7 +681,7 @@ export class SessionQuerySqlite extends SessionQueryService { const db = this._requireDb() const live = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation FROM temp.live_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -702,7 +691,7 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistenceBinding.service !== undefined) { const persisted = db.prepare( `SELECT - id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, agent_preset, generation FROM persisted_sessions WHERE id = ?`, ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined @@ -750,6 +739,25 @@ export class SessionQuerySqlite extends SessionQueryService { } } +/** + * The header columns both session upserts bind, in the order their INSERT + * lists them. The two statements differ only in what they append after these. + * @param header - the session header being written. + * @returns one bound value per header column. + */ +function headerBindings(header: SessionHeader): (string | number | null)[] { + return [ + header.id, + header.version, + header.createdAt, + header.cwd ?? null, + header.parentSession ?? null, + header.seedLength ?? null, + header.delegationDepth ?? null, + header.agentPreset ?? null, + ] +} + function selectedDocumentsSql(): { sql: string } { return { sql: `WITH candidates AS ( @@ -761,6 +769,7 @@ function selectedDocumentsSql(): { sql: string } { ps.parent_session AS parent_session, ps.seed_length AS seed_length, ps.delegation_depth AS delegation_depth, + ps.agent_preset AS agent_preset, 0 AS live, 1 AS persisted, CAST(pd.seq AS INTEGER) AS seq, @@ -783,6 +792,7 @@ function selectedDocumentsSql(): { sql: string } { ls.parent_session AS parent_session, ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, + ls.agent_preset AS agent_preset, 1 AS live, CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, @@ -891,6 +901,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && a.parentSession === b.parentSession && a.seedLength === b.seedLength && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) + && a.agentPreset === b.agentPreset } function rowHeader(row: SessionHeaderRow): SessionHeader { @@ -902,6 +913,7 @@ function rowHeader(row: SessionHeaderRow): SessionHeader { ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, ...row.seed_length === null ? {} : { seedLength: row.seed_length }, ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth }, + ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset }, } } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 59cec8819c..6ad031f77f 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ -export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 7 +export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 8 /** SQLite application id protecting unrelated databases from derived resets. */ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 @@ -118,6 +118,7 @@ function ensurePersistentSchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + agent_preset TEXT, revision TEXT NOT NULL, generation INTEGER NOT NULL ) STRICT @@ -147,6 +148,7 @@ function ensureTemporarySchema(db: DatabaseSync): void { parent_session TEXT, seed_length INTEGER, delegation_depth INTEGER, + agent_preset TEXT, fingerprint TEXT NOT NULL, persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index c5cbccbf24..8427ebedc4 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -280,7 +280,10 @@ describe('SQLite session search', () => { it('searches two-character Unicode61 tokens in live-only sessions', async () => { const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) const session = ctx.sessions.create(SessionId('live'), { - meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2 }, + // agentPreset rides along: the index rebuilds the header a caller reads, + // and a session listed under the wrong composition is a lie about what it + // ran. The full-header comparison below is what pins every column. + meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2, agentPreset: 'minimal' }, }) session.append( 'user/message', diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 1ccd0b5286..a1fcc59e7f 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md -README.md: b7fa8fc2918711dd24eeba67132a267452d401f9 -README.zh.md: cf044b937f7ae6d0464603a1b59ad9489423ebe0 +README.md: 628833513a8092280970230c8657a50d00db4527 +README.zh.md: 4eb2d4f2bebf9ed17190ef3cb21a2bc3c8d9123b diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index b7fa8fc291..628833513a 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence session.jsonl # only with compression: 'none' ``` -- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `agentPreset` is durable because it decides the resumed session's tools and prompt — restoring a different composition would replay history the model can no longer act on. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. - The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index cf044b937f..4eb2d4f2be 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -14,7 +14,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d session.jsonl # only with compression: 'none' ``` -- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 +- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`agentPreset` 必须持久化,因为它决定了被恢复会话的工具与提示词——恢复成另一套组装,就会重放模型已无法据以行动的历史。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。 - 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。 - 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。 - 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。 diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 96e8221c65..fd62306b1a 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -39,6 +39,7 @@ export interface HeaderLine { seedLength?: number origin?: 'subagent' delegationDepth: number + agentPreset?: string } /** @@ -57,6 +58,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, ...header.origin !== undefined ? { origin: header.origin } : {}, delegationDepth: header.delegationDepth ?? 0, + ...header.agentPreset !== undefined ? { agentPreset: header.agentPreset } : {}, } } @@ -78,6 +80,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, ...line.origin !== undefined ? { origin: line.origin } : {}, delegationDepth: line.delegationDepth, + ...line.agentPreset !== undefined ? { agentPreset: line.agentPreset } : {}, } } @@ -98,6 +101,8 @@ function isHeaderLine(value: unknown): value is HeaderLine { && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) && ((value as { origin?: unknown }).origin === undefined || (value as { origin?: unknown }).origin === 'subagent') + && ((value as { agentPreset?: unknown }).agentPreset === undefined + || typeof (value as { agentPreset?: unknown }).agentPreset === 'string') ) } diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 636db0bc8c..c7b4ab8841 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -817,6 +817,27 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) }) + it('round-trips the agent preset a session was composed from', () => { + const line = toHeaderLine({ + version: 0, + id: SessionId('composed'), + createdAt: 1, + delegationDepth: 0, + agentPreset: 'minimal', + }) + const log = `${JSON.stringify(line)}\n` + + // The preset decides the resumed session's tools and prompt; dropping it + // on disk would restore a composition the logged history contradicts. + expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal') + }) + + it('rejects a session header whose agentPreset is not a string', () => { + const log = '{"type":"session","version":0,"id":"bad-preset","createdAt":1,"delegationDepth":0,"agentPreset":7}\n' + + expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) + }) + it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }), diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index fc2b10fa96..b26e273cf1 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -380,8 +380,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers private writeRow(meta: SessionHeader): void { this.db.prepare(` INSERT INTO sessions - (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, @@ -389,7 +389,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers parent_session = excluded.parent_session, seed_length = excluded.seed_length, origin = excluded.origin, - delegation_depth = excluded.delegation_depth + delegation_depth = excluded.delegation_depth, + agent_preset = excluded.agent_preset `).run( meta.id, meta.version, @@ -399,6 +400,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.seedLength ?? null, meta.origin ?? null, meta.delegationDepth ?? null, + meta.agentPreset ?? null, randomUUID(), ) } diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index a9830316a8..c7a4de7233 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 13 +export const SCHEMA_VERSION = 14 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -42,6 +42,7 @@ export interface SessionRow { /** Monotonic log-change token incremented in each mutating transaction. */ revision: number delegation_depth: number | null + agent_preset: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -125,6 +126,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM seed_length INTEGER, origin TEXT, delegation_depth INTEGER, + agent_preset TEXT, incarnation TEXT NOT NULL, revision INTEGER NOT NULL ) STRICT; @@ -184,6 +186,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.origin !== null ? { origin: row.origin } : {}, ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, + ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, } } diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index ab602e1c4d..afaa060490 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -172,6 +172,7 @@ describe('rowToMeta', () => { incarnation: 'with-origin', revision: 1, delegation_depth: null, + agent_preset: null, })).toMatchObject({ id: 'with-origin', origin: 'subagent' }) }) @@ -187,8 +188,27 @@ describe('rowToMeta', () => { incarnation: 'fractional', revision: 1, delegation_depth: null, + agent_preset: null, })).toThrow('stored session createdAt must be a non-negative safe integer') }) + + it('restores the agent preset a session was composed from', () => { + // The preset decides the resumed session's tools and prompt; a row that + // dropped it would rebuild a composition the stored history contradicts. + expect(rowToMeta({ + id: 'composed', + version: 0, + created_at: 1, + cwd: null, + parent_session: null, + seed_length: null, + origin: null, + incarnation: 'composed', + revision: 1, + delegation_depth: null, + agent_preset: 'minimal', + })).toMatchObject({ agentPreset: 'minimal' }) + }) }) describe('SessionPersistenceSqlite: durability and crash semantics', () => { @@ -638,7 +658,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(13) + expect(SCHEMA_VERSION).toBe(14) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 208c850363..1d0364d1f2 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -134,10 +134,22 @@ interface UnitCell { observedSeq: number } -/** One live registration: the unit plus its per-session cells (dropped whole on disposal). */ +/** + * One live registration: the unit plus its per-session cells (dropped whole + * once the last registrant releases it). + * + * `refs` exists because one unit definition already serves every session — the + * cells are keyed by `Session` — while the registrants are now per-session: + * an agent preset mounts the same tool package once per agent, so N sessions + * on one preset register the same key N times. Without a count the first + * registrant would own the disposer, and its session ending would strip the + * projection from every other live session. + */ interface Registration { readonly def: ErasedDefinition readonly cells: WeakMap<Session, UnitCell> + /** Live registrants sharing this unit; the last one out removes the key. */ + refs: number } /** @@ -149,9 +161,12 @@ interface Registration { * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. Duplicate keys throw. Domain + * and clients read it as capability absence. Domain * plugins register under `ctx.inject(['sessionProjections'], …)` so headless - * assemblies without the registry stay unaffected. + * assemblies without the registry stay unaffected. Registrants sharing a key + * share one unit and are counted: the same tool package mounted in N agent + * presets registers N times, and the key survives until the last one + * unloads. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map<string, Registration>() @@ -182,12 +197,25 @@ export class SessionProjectionRegistry extends Service { } const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { const key = definition.key as string - if (this.registrations.has(key)) { - throw new Error(`session projection key ${JSON.stringify(key)} is already registered`) + const existing = this.registrations.get(key) + if (existing === undefined) { + this.registrations.set(key, { def: definition, cells: new WeakMap(), refs: 1 }) + } else { + // A differing `stateVersion` is the one incompatibility this can name: + // the versioned contract says the cached state shape differs, so the + // two registrants cannot share cells. Anything else about a definition + // is functions, which no runtime comparison can tell apart. + if (existing.def.stateVersion !== definition.stateVersion) { + throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(definition.stateVersion)}`) + } + existing.refs += 1 } - this.registrations.set(key, { def: definition, cells: new WeakMap() }) yield () => { - this.registrations.delete(key) + const live = this.registrations.get(key) + /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ + if (live === undefined) return + live.refs -= 1 + if (live.refs === 0) this.registrations.delete(key) } }.bind(this), 'sessionProjections.register()') return () => void dispose() diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index e54f1f2468..5d0f208743 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -127,14 +127,45 @@ describe('SessionProjectionRegistry drive', () => { expect(snapshot.values['test/marks']).toEqual({ marks: [] }) }) - it('rejects duplicate keys loud and keeps the first unit', async () => { + it('shares one unit between registrants of the same key', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) - expect(() => ctx.sessionProjections.register(marksUnit())).toThrow(/"test\/marks" is already registered/) + + // One definition already serves every session (cells are keyed by + // Session), and registrants are per-session now: an agent preset mounts + // the same tool package once per agent. + expect(() => ctx.sessionProjections.register(marksUnit())).not.toThrow() mark(session, ['kept']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) }) + it('keeps the unit until the last registrant releases it', async () => { + const { ctx, session } = await harness() + const first = ctx.sessionProjections.register(marksUnit()) + const second = ctx.sessionProjections.register(marksUnit()) + mark(session, ['kept']) + + first() + + // The regression this counts against: one session ending used to strip + // the projection from every other live session, because the first + // registrant owned the only disposer. + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) + second() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) + + it('refuses to share a key across a stateVersion change', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + + // The one incompatibility a runtime comparison can name: the versioned + // contract says the cached state shape differs, so the two cannot share + // cells. Everything else about a definition is functions. + expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) + .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) + }) + it('rejects a non-integer or negative stateVersion at register time', async () => { const { ctx } = await harness() expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/) diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 2c6f84ca3a..12d6de8dde 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill/README.md -README.md: 3dc2bcfa5775736717bdebcb92329d5655198234 -README.zh.md: e57e389f9080cfc763916111cf80ea97980f01e7 +README.md: 9c27a271f03f33d2b53984a6a5c18082ccc6169a +README.zh.md: 085dec3e342c2f42a39d28b995dcb4e2cf38f440 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 3dc2bcfa57..9c27a271f0 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -6,15 +6,17 @@ Pure agent skill provider registry. This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local). +The registry is host+per-scope layered over [`@deepseek-ai/dsh-scope`](../../core/scope), the shape the tools registry established: a registration files into the layer of its calling context's scope — host rows and repository plugins land in the global layer, a plugin mounted by an agent preset's standing composition lands in that preset's layer — and a read merges the global layer with the viewing scope's chain, the nearest layer winning a duplicate name outright while rank decides duplicates only within one layer. + ## Service: `SkillService` (ctx key: `skills`) ### Public API -- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. -- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached. -- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. -- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy. -- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. +- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by `provider.name`, unique within the calling context's layer. Duplicate names in one layer throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. +- `ctx.skills.snapshot({ cwd?, signal?, scope? })` Returns the invocation-neutral `{ skills, complete }` observation for the viewing scope's merged layers. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached. +- `ctx.skills.list({ cwd?, signal?, scope? })` Borrows the readonly view options, then returns every winning summary for the current workspace, merged across the global layer and the viewing scope's chain and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. +- `ctx.skills.get(name, { cwd?, signal?, scope? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy. +- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill into the calling context's layer, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations in one layer are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. ### Events @@ -49,7 +51,7 @@ A provider factory runs synchronously and receives one registration-scoped contr The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. +Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Within one layer, duplicate names resolve by rank, provider registration order, then provider-local order; across layers the nearest scope's entry wins the name. Summaries are sorted by skill name. Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog. @@ -74,4 +76,4 @@ No direct prompt effect. The named consumer owns the durable initial catalog and - **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism. - **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running. - **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics. -- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. +- **Duplicate resolution is first-wins** — later lower-priority candidates within a layer are logged and hidden, and a nearer layer shadows a farther one silently; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index e57e389f90..085dec3e34 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -6,15 +6,17 @@ 该包负责 `ctx.skills` 接口。它不知道 skill 来自本地文件、嵌入式插件数据、HTTP 还是其他后端;提供方通过 `ctx.skills.registerProvider(...)` 注册这些来源。已发布的本地实现是 [`@deepseek-ai/dsh-skill-local`](../skill-local)。 +注册表基于 [`@deepseek-ai/dsh-scope`](../../core/scope) 采用宿主 + 按 scope 的分层结构,即工具注册表确立的形态:注册落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,由 agent preset 常驻组合挂载的插件落入该 preset 的层——读取时将全局层与观察 scope 的链合并,最近层直接赢得重名,rank 只在单层内裁决重名。 + ## 服务:`SkillService`(ctx 键:`skills`) ### 公开 API -- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 -- `ctx.skills.snapshot({ cwd?, signal? })` 返回与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。 -- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 -- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。 -- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 +- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后以在调用方上下文所在层内唯一的 `provider.name` 注册其只读结果。同层重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 +- `ctx.skills.snapshot({ cwd?, signal?, scope? })` 返回观察 scope 各层合并后、与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。 +- `ctx.skills.list({ cwd?, signal?, scope? })` 借用只读视图选项,然后返回当前工作区中的全部胜出摘要;这些摘要在全局层与观察 scope 链之间合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 +- `ctx.skills.get(name, { cwd?, signal?, scope? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。 +- `ctx.skills.register(skill): () => void` 将只读运行时嵌入式 skill 注册进调用方上下文所在层,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同层同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 ### 事件 @@ -49,7 +51,7 @@ 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读约定。 -违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 +违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。单层内重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突;跨层则由最近 scope 的条目赢得名称。摘要按 skill 名称排序。 定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 @@ -74,4 +76,4 @@ - **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个响应取消但速度缓慢的提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不响应取消的提供方持续运行的工作。 - **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。 -- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 +- **重复解析使用先到先得**:系统会记录并隐藏层内较晚出现的低优先级候选项,较近的层会静默遮蔽较远的层;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index f77f56f6d1..da51610ec3 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -35,6 +36,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 18c878d74b..c013933547 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -12,6 +12,8 @@ import { Context, Service } from 'cordis' import { assertNever } from '@deepseek-ai/dsh-llm' +import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import z from 'schemastery' import type Schema from 'schemastery' @@ -106,6 +108,17 @@ export interface SkillLookupOptions { readonly signal?: AbortSignal | undefined } +/** + * Registry read options: provider lookup context plus the viewing scope. + * The registry consumes `scope` to select layers; providers receive the same + * borrowed options object and read only their {@link SkillLookupOptions} + * contract from it. + */ +export interface SkillViewOptions extends SkillLookupOptions { + /** Viewing scope (the calling agent); omitted reads the global layer alone. */ + readonly scope?: ScopeKey | undefined +} + /** * Return whether a skill may be advertised to and loaded by a model. * @param skill - skill metadata carrying resolved invocation controls. @@ -290,17 +303,56 @@ interface IndexedCandidate { provider: SkillProvider providerOrder: number localOrder: number + /** Owning layer, so a stale-definition invalidation can verify the exact registration is still live. */ + layer: SkillLayer } -interface CollectResult { +/** One provider registration retained by its layer. */ +interface RegisteredProvider { + provider: SkillProvider + /** Service-wide monotonic registration order, the within-layer rank tiebreak. */ + order: number +} + +interface LayerCollectResult { entries: IndexedCandidate[] cacheable: boolean } +interface CollectResult { + entries: Map<string, IndexedCandidate> + cacheable: boolean +} + +/** One scope's complete skill-registry contribution. */ +class SkillLayer implements ScopeLayer { + /** Providers registered through contexts carrying this scope, insertion-ordered. */ + readonly providers: NamedEntries<RegisteredProvider> + /** Runtime skills registered through contexts carrying this scope. */ + readonly runtime = new Map<string, SkillDefinition>() + + constructor(scope: ScopeKey | undefined) { + this.providers = new NamedEntries(name => new Error(scope === undefined + ? `a skill provider named "${name}" is already registered` + : `a skill provider named "${name}" is already registered in this scope`)) + } + + /** Whether every contribution table in this aggregate layer is empty. */ + isEmpty(): boolean { + return this.providers.isEmpty() && this.runtime.size === 0 + } +} + /** - * Registry of skill providers. It merges provider catalogs with stable - * first-wins duplicate handling, exposes sorted invocation-neutral summaries, and - * loads full skill bodies on demand. + * Layered registry of skill providers, the host+per-scope shape the tools + * registry established. A registration files into the layer of its calling + * context's scope ({@link scopeOf}): host rows and repository plugins land in + * the global layer, while a plugin mounted by an agent preset's standing + * composition lands in that preset's layer. A read merges the global layer + * with the viewing scope's chain — the nearest layer's entry wins a duplicate + * name outright, and the rank order decides duplicates only within one layer. + * It exposes sorted invocation-neutral summaries and loads full skill bodies + * on demand. */ export class SkillService extends Service { static Config: Schema<Config> = z.object({ @@ -308,12 +360,16 @@ export class SkillService extends Service { }) private readonly collectCacheMaxEntries: number - private readonly providers = new Map<string, { provider: SkillProvider; order: number }>() - private readonly runtime = new Map<string, SkillDefinition>() - private readonly collectCache = new Map<string, IndexedCandidate[]>() - private providerRevision = 0 + private readonly layers = new ScopedLayers<SkillLayer>( + scope => new SkillLayer(scope), + () => { this.invalidateCache() }, + ) + private readonly collectCache = new Map<string, Map<string, IndexedCandidate>>() + private revision = 0 private nextProviderOrder = 0 - private runtimeRevision = 0 + /** Stable identities for cache keys; scope keys are opaque identity-compared objects. */ + private readonly scopeIds = new WeakMap<ScopeKey, number>() + private nextScopeId = 1 constructor(ctx: Context, config: Config = {}) { super(ctx, 'skills') @@ -322,21 +378,27 @@ export class SkillService extends Service { } /** - * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and - * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters - * the provider and invalidates catalog caches. + * Register a borrowed same-process provider synchronously during plugin + * apply, into the calling context's layer: a scoped context (an agent + * preset's standing mount) registers for that scope alone, an unscoped + * context registers globally. Duplicate names within one layer and reserved + * names throw; remote initialization belongs in `list()`. Fiber disposal + * unregisters the provider and invalidates catalog caches. * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void { const lifecycle = new AbortController() - let active = false + let registration: { layer: SkillLayer; name: string } | undefined let provider: SkillProvider const control: SkillProviderControl = { signal: lifecycle.signal, invalidate: () => { - if (active) this.invalidateProvider(provider) + const active = registration + if (active !== undefined && active.layer.providers.get(active.name)?.provider === provider) { + this.invalidateCache() + } }, } try { @@ -345,26 +407,21 @@ export class SkillService extends Service { if (name === RUNTIME_PROVIDER) { throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) } - if (this.providers.has(name)) { - throw new Error(`a skill provider named "${name}" is already registered`) - } - const providers = this.providers const order = this.nextProviderOrder - const invalidateCache = (): void => { this.invalidateCache() } this.nextProviderOrder += 1 - const dispose = this.ctx.effect(function* () { - active = true - providers.set(name, { provider, order }) - invalidateCache() - yield () => { - active = false - providers.delete(name) - lifecycle.abort(new Error(`skill provider "${name}" disposed`)) - invalidateCache() - } - }, 'skills.registerProvider()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity - return dispose + return this.layers.effect( + this.ctx, + (layer) => { + const undo = layer.providers.insert(name, { provider, order }) + registration = { layer, name } + return () => { + registration = undefined + undo() + lifecycle.abort(new Error(`skill provider "${name}" disposed`)) + } + }, + { label: 'skills.registerProvider()' }, + ) } catch (error) { lifecycle.abort(error) throw error @@ -372,16 +429,19 @@ export class SkillService extends Service { } /** - * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which - * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and - * receives a no-op disposer so it cannot remove the winner. + * Register a borrowed readonly runtime skill into the calling context's + * layer. Project entries outrank runtime entries, which outrank user + * entries, within one layer. Same-name runtime entries in one layer are + * first-wins; a duplicate logs a warning and receives a no-op disposer so + * it cannot remove the winner. * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void { validateRuntimeSkill(skill) - const existing = this.runtime.get(skill.name) - if (existing !== undefined) { + const scope = scopeOf(this.ctx) + const existingLayer = scope === undefined ? this.layers.global : this.layers.peek(scope) + if (existingLayer !== undefined && existingLayer.runtime.has(skill.name)) { this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`) return () => {} } @@ -390,21 +450,14 @@ export class SkillService extends Service { invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true }, provider: skill.provider ?? RUNTIME_PROVIDER, } - const runtime = this.runtime - const updateRevision = (): void => { this.runtimeRevision += 1 } - const invalidateCache = (): void => { this.invalidateCache() } - const dispose = this.ctx.effect(function* () { - runtime.set(definition.name, definition) - updateRevision() - invalidateCache() - yield () => { - runtime.delete(definition.name) - updateRevision() - invalidateCache() - } - }, 'skills.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose + return this.layers.effect( + this.ctx, + (layer) => { + layer.runtime.set(definition.name, definition) + return () => { layer.runtime.delete(definition.name) } + }, + { label: 'skills.register()' }, + ) } /** @@ -412,10 +465,10 @@ export class SkillService extends Service { * model or user invocation policy at their operational boundary. Lookup * options and provider candidates are readonly same-process values borrowed * throughout discovery. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns all sorted winning summaries. */ - async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> { + async list(options: SkillViewOptions = {}): Promise<SkillSummary[]> { return (await this.snapshot(options)).skills } @@ -423,15 +476,14 @@ export class SkillService extends Service { * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. - * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery. * @returns sorted summaries plus discovery-completeness state. */ - async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> { + async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot> { const collected = await this.collect(options) return { - skills: collected.entries - .map(entry => entry.candidate) - .map(toSummary) + skills: [...collected.entries.values()] + .map(entry => toSummary(entry.candidate)) .sort(compareSkillSummary), complete: collected.cacheable, } @@ -442,14 +494,15 @@ export class SkillService extends Service { * provider. Cancellation is rechecked after selection, including cache hits, and raced against * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. - * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @param options - view options; `scope` selects the viewing agent's layers, + * `cwd` selects workspace-sensitive skills, and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ - async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> { + async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined> { if (!isSkillName(name)) return undefined const collected = await this.collect(options) throwIfAborted(options.signal) - const match = collected.entries.find(entry => entry.candidate.name === name) + const match = collected.entries.get(name) if (match === undefined) return undefined const definition = await waitWithAbort( match.provider.get(match.candidate, options), @@ -458,25 +511,27 @@ export class SkillService extends Service { if (definition === undefined) return undefined validateDefinition(definition) if (definition.name !== match.candidate.name) { - this.invalidateProvider(match.provider) + this.invalidateEntry(match) return undefined } return definition } - private async collect(options: SkillLookupOptions): Promise<CollectResult> { + private async collect(options: SkillViewOptions): Promise<CollectResult> { throwIfAborted(options.signal) let attempt = 1 while (true) { - const providerRevision = this.providerRevision - const runtimeRevision = this.runtimeRevision - const key = collectCacheKey(options, providerRevision, runtimeRevision) + const revision = this.revision + // The chain is part of the key rather than assumed stable: a blank-session + // recompose re-parents an existing scope without touching this registry, + // and only a chain-bearing key makes the next read see the new preset. + const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision) const cached = this.collectCache.get(key) if (cached !== undefined) return { entries: cached, cacheable: true } const result = await this.collectFresh(options) throwIfAborted(options.signal) - if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) { + if (revision !== this.revision) { if (attempt < MAX_COLLECT_ATTEMPTS) { attempt += 1 continue @@ -494,8 +549,24 @@ export class SkillService extends Service { } } - private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> { - const collected = await this.listAllCandidates(options) + private async collectFresh(options: SkillViewOptions): Promise<CollectResult> { + // Global first, then existing chain overlays farthest ancestor first and + // the exact scope last, so the nearest layer's same-name entry replaces + // the farther ones — the tools registry's shadowing rule. Rank decides + // duplicates only within one layer. + const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)] + const merged = new Map<string, IndexedCandidate>() + let cacheable = true + for (const layer of layers) { + const collected = await this.collectLayer(layer, options) + if (!collected.cacheable) cacheable = false + for (const entry of collected.entries) merged.set(entry.candidate.name, entry) + } + return { entries: merged, cacheable } + } + + private async collectLayer(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> { + const collected = await this.listLayerCandidates(layer, options) collected.entries.sort(compareIndexedCandidates) const seen = new Set<string>() const result: IndexedCandidate[] = [] @@ -511,21 +582,22 @@ export class SkillService extends Service { return { entries: result, cacheable: collected.cacheable } } - private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> { + private async listLayerCandidates(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> { throwIfAborted(options.signal) const candidates: IndexedCandidate[] = [] let cacheable = true let runtimeOrder = 0 - for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) { + for (const skill of [...layer.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) { candidates.push({ candidate: runtimeCandidate(skill), provider: RUNTIME_SKILL_PROVIDER, providerOrder: -1, localOrder: runtimeOrder, + layer, }) runtimeOrder += 1 } - for (const { provider, order } of [...this.providers.values()]) { + for (const { provider, order } of [...layer.providers.values()]) { let localOrder = 0 let output: unknown try { @@ -540,7 +612,7 @@ export class SkillService extends Service { if (!observation.complete) cacheable = false for (const candidate of observation.candidates) { validateCandidate(candidate, provider.name) - candidates.push({ candidate, provider, providerOrder: order, localOrder }) + candidates.push({ candidate, provider, providerOrder: order, localOrder, layer }) localOrder += 1 } } @@ -548,14 +620,29 @@ export class SkillService extends Service { } private invalidateCache(): void { - this.providerRevision += 1 + this.revision += 1 this.collectCache.clear() this.notifyChange() } - private invalidateProvider(provider: SkillProvider): void { + /** Invalidate after a stale definition load, only while the exact registration that produced the entry is still live. */ + private invalidateEntry(entry: IndexedCandidate): void { /* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */ - if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache() + if (entry.layer.providers.get(entry.provider.name)?.provider === entry.provider) this.invalidateCache() + } + + private scopeId(key: ScopeKey): number { + let id = this.scopeIds.get(key) + if (id === undefined) { + id = this.nextScopeId + this.nextScopeId += 1 + this.scopeIds.set(key, id) + } + return id + } + + private collectCacheKey(cwd: string | undefined, chain: ScopeKey[], revision: number): string { + return JSON.stringify({ cwd, scopes: chain.map(key => this.scopeId(key)), revision }) } /** Notify catalog observers without making their refresh work load-bearing. */ @@ -729,10 +816,6 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { } } -function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string { - return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision }) -} - function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> { if (signal === undefined) return promise throwIfAborted(signal) diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index d48263cfe0..a5e03b610a 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import SkillService, { isModelInvocable, isUserInvocable, @@ -49,6 +50,13 @@ function registerProvider(ctx: Context, provider: SkillProvider): () => void { return ctx.skills.registerProvider(() => provider) } +/** The skills service as a scoped caller resolves it (scope contexts declare no inject). */ +function scopedSkills(ctx: Context): SkillService { + const skills = ctx.get('skills') + if (skills === undefined) throw new Error('skills service missing') + return skills +} + describe('SkillService registry', () => { it('registers providers, resolves duplicates first-wins, and disposes providers', async () => { const ctx = new Context() @@ -894,6 +902,26 @@ describe('SkillService registry', () => { await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined() }) + it('propagates a load failure raced against an armed abort signal', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, { + name: 'failing-loader', + list: () => Promise.resolve([{ + name: 'failing-skill', + description: 'Failing', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'failing-loader', + source: 'test', + rank: 10, + locator: 'failing', + }]), + get: () => Promise.reject(new Error('load failed')), + }) + const controller = new AbortController() + await expect(ctx.skills.get('failing-skill', { signal: controller.signal })).rejects.toThrow('load failed') + }) + it('contains a provider rejection whose string coercion throws', async () => { const ctx = new Context() await ctx.plugin(SkillService) @@ -1076,3 +1104,168 @@ describe('renderSkillContent', () => { expect(text).toContain('Keep </skill_content> and <tags> as-is.') }) }) + +describe('SkillService scoped layers', () => { + it('files a scoped provider into its layer and merges it into that scope view only', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, new MemoryProvider([memorySkill('global-skill', 'Global', 100)])) + const preset = createScope(ctx, { preset: 'a' }) + const presetProvider: SkillProvider = { + name: 'preset-local', + async list() { + return [{ + name: 'preset-skill', + description: 'Preset', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'preset-local', + source: 'preset', + rank: 300, + locator: { content: 'Preset body.' }, + }] + }, + async get(candidate) { + return { ...candidate, content: (candidate.locator as { content: string }).content } + }, + } + scopedSkills(preset.ctx).registerProvider(() => presetProvider) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['global-skill']) + const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) }) + expect(scoped.map(skill => skill.name)).toEqual(['global-skill', 'preset-skill']) + expect((await ctx.skills.get('preset-skill', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.') + expect(await ctx.skills.get('preset-skill')).toBeUndefined() + await preset.dispose() + }) + + it('lets the nearest layer win a duplicate name regardless of rank', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, new MemoryProvider([memorySkill('shared-name', 'Global wins ranks', 10)])) + const preset = createScope(ctx, { preset: 'shadow' }) + scopedSkills(preset.ctx).registerProvider(() => ({ + name: 'preset-local', + async list() { + return [{ + name: 'shared-name', + description: 'Preset shadow', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'preset-local', + source: 'preset', + rank: 900, + locator: { content: 'Preset shadow body.' }, + }] + }, + async get(candidate: SkillCandidate) { + return { ...candidate, content: (candidate.locator as { content: string }).content } + }, + })) + + const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) }) + expect(scoped).toHaveLength(1) + expect(scoped[0]?.description).toBe('Preset shadow') + expect((await ctx.skills.get('shared-name', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset shadow body.') + expect((await ctx.skills.list())[0]?.description).toBe('Global wins ranks') + await preset.dispose() + }) + + it('resolves the scope chain so an agent key inherits its preset layer and recompose follows the new parent', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const presetA = createScope(ctx, { preset: 'a' }) + const presetB = createScope(ctx, { preset: 'b' }) + for (const [scope, label] of [[presetA, 'a'], [presetB, 'b']] as const) { + scopedSkills(scope.ctx).register({ + name: `skill-${label}`, + description: `Skill ${label}`, + source: 'preset', + content: `Body ${label}.`, + }) + } + const agentKey = {} + const binding = bindScopeParent(agentKey, scopeOf(presetA.ctx) as object) + expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-a']) + // A blank-session recompose re-links the same key through its binding + // without any registry write. + binding.rebind(scopeOf(presetB.ctx) as object) + expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-b']) + await presetA.dispose() + await presetB.dispose() + }) + + it('scopes provider-name uniqueness per layer and reports scoped duplicates distinctly', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + registerProvider(ctx, new MemoryProvider([])) + const presetA = createScope(ctx, { preset: 'a' }) + const presetB = createScope(ctx, { preset: 'b' }) + scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([memorySkill('a-only', 'A', 100)])) + scopedSkills(presetB.ctx).registerProvider(() => new MemoryProvider([memorySkill('b-only', 'B', 100)])) + expect(() => scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([]))) + .toThrow('a skill provider named "memory" is already registered in this scope') + expect((await ctx.skills.list({ scope: scopeOf(presetA.ctx) })).map(skill => skill.name)).toEqual(['a-only']) + expect((await ctx.skills.list({ scope: scopeOf(presetB.ctx) })).map(skill => skill.name)).toEqual(['b-only']) + await presetA.dispose() + await presetB.dispose() + }) + + it('keeps runtime duplicate handling per layer and shadows a global runtime name', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const warn = vi.fn() + ctx.logger.warn = warn as never + ctx.skills.register({ name: 'told-twice', description: 'Global runtime', source: 'runtime', content: 'Global body.' }) + const preset = createScope(ctx, { preset: 'runtime' }) + const disposeShadow = scopedSkills(preset.ctx).register({ + name: 'told-twice', + description: 'Preset runtime', + source: 'preset', + content: 'Preset body.', + }) + expect(warn).not.toHaveBeenCalled() + scopedSkills(preset.ctx).register({ name: 'told-twice', description: 'Ignored', source: 'preset', content: 'Ignored.' }) + expect(warn).toHaveBeenCalledWith('runtime skill "told-twice" ignored because it is already registered') + expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.') + expect((await ctx.skills.get('told-twice'))?.content).toBe('Global body.') + disposeShadow() + expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Global body.') + await preset.dispose() + }) + + it('drops a disposed scoped registration from its scope view and notifies change', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const changes = vi.fn() + ctx.on('skills/change', changes) + const preset = createScope(ctx, { preset: 'hmr' }) + const provider = new MemoryProvider([memorySkill('scoped-skill', 'Scoped', 100)]) + scopedSkills(preset.ctx).registerProvider(() => provider) + expect((await ctx.skills.list({ scope: scopeOf(preset.ctx) })).map(skill => skill.name)).toEqual(['scoped-skill']) + const notified = changes.mock.calls.length + await preset.dispose() + expect(changes.mock.calls.length).toBeGreaterThan(notified) + expect(await ctx.skills.list({ scope: scopeOf(preset.ctx) })).toEqual([]) + }) + + it('invalidates through a scoped provider control only while its exact registration is live', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const preset = createScope(ctx, { preset: 'invalidate' }) + const provider = new MemoryProvider([memorySkill('watched', 'Watched', 100)]) + let control: { invalidate: () => void } | undefined + const dispose = scopedSkills(preset.ctx).registerProvider((given) => { + control = given + return provider + }) + const scope = scopeOf(preset.ctx) + expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['watched']) + provider.replace([memorySkill('replaced', 'Replaced', 100)]) + control?.invalidate() + expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['replaced']) + dispose() + provider.replace([memorySkill('ignored', 'Ignored', 100)]) + control?.invalidate() + expect(await ctx.skills.list({ scope })).toEqual([]) + await preset.dispose() + }) +}) diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index 82e62d7c91..8fb99e3b59 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../core/scope" + }, { "path": "../../llm/llm" }, diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index f78825d1f7..3d40fd1610 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md -README.md: fd2cb2dc00994a79856ad605ce126387c27a9f65 -README.zh.md: 3fb76e079033ef39e475e6795cf28407e52a83cc +README.md: 704eb7eb1f611c20f76ce79190326296ff63da42 +README.zh.md: 09fb5b954ea595b962f7670ce09db1ae22dce29c diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index fd2cb2dc00..704eb7eb1f 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -12,7 +12,7 @@ At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing cannot decide whether a republish is needed and consumers never re-parse the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary. -The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. +The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Identity is compared against the definition this plugin registered rather than a lookup of its own name, so the plugin works mounted globally or inside one agent's composition, where `register()` files into that agent's layer alone. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. `catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 3fb76e0790..09fb5b954e 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -12,7 +12,7 @@ 每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `<system-reminder>` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 -如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 +如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。身份比对针对本插件所注册的那个定义,而非按自身名字回查,因此本插件既可全局挂载,也可挂在单个 agent 的组装内——在后者中 `register()` 只归档进该 agent 的分层。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 `catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index bc9a1634a1..634fb7ce02 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -128,7 +128,9 @@ export function apply(ctx: Context, config: Config = {}): void { if (!isSkillName(args.name)) { throw new Error(`invalid skill name "${args.name}"`) } - const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal } + // The agent is its own scope key, so the lookup resolves the layered + // registry exactly as this agent's composition sees it. + const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal, scope: exec.agent } const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name) if (!summary) { throw new Error(`skill "${args.name}" is unknown or no longer available`) @@ -157,11 +159,6 @@ export function apply(ctx: Context, config: Config = {}): void { }, }) ctx.tools.register(skillTool) - const registeredSkillTool = ctx.tools.get(skillTool.name) - /* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */ - if (registeredSkillTool === undefined) { - throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') - } // User-explicit skill invocation: a claimed user message whose first line // starts with `/<name>` naming a user-invocable skill is a deterministic @@ -186,7 +183,7 @@ export function apply(ctx: Context, config: Config = {}): void { const names = invokedSkillNames(messages) if (names.length === 0) return decision signal.throwIfAborted() - const lookup = { cwd: agent.session.header.cwd, signal } + const lookup = { cwd: agent.session.header.cwd, signal, scope: agent } const injections: UserMessage[] = [] for (const name of names) { const skill = await ctx.skills.get(name, lookup) @@ -208,6 +205,11 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. + // + // The comparison is against the definition this plugin registered, not against + // a lookup of its own name: `register()` files into the CALLING context's + // scope, so a plugin mounted inside an agent preset registers for that agent + // alone and an unscoped lookup correctly finds nothing. ctx.on('agent/pre-step', async ( { agent, signal }, next, @@ -215,9 +217,9 @@ export function apply(ctx: Context, config: Config = {}): void { const decision = await next() if (decision.kind === 'reject') return decision signal.throwIfAborted() - const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool + const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool const snapshot = toolVisible - ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) + ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal, scope: agent }) : { skills: [], complete: true } signal.throwIfAborted() if (!snapshot.complete) return decision diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 24d9c35591..496fe81d46 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -640,6 +640,43 @@ describe('dsh-tool-skill', () => { expect(JSON.stringify(result.content)).not.toContain('First body.') }) + it('resolves the layered registry as the calling agent sees it', async () => { + const home = await tempDir('tool-scoped-layer') + const ctx = await setup(home) + const { agent, scope } = await mintAgentScope(ctx, '/workspace/scoped') + const scopedSkills = scope.ctx.get('skills') + if (scopedSkills === undefined) throw new Error('skills service missing') + scopedSkills.register({ + name: 'preset-only-skill', + description: 'Visible to the scoped agent alone', + source: 'preset', + content: 'Preset-only body.', + }) + + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('preset-only-skill') + expect(JSON.stringify(await composePrefix(ctx, '/workspace/other'))).not.toContain('preset-only-skill') + + const scoped = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('scoped-load'), + name: 'skill', + arguments: { name: 'preset-only-skill' }, + agent, + }) + expect(scoped.isError).toBe(false) + expect(JSON.stringify(scoped.content)).toContain('Preset-only body.') + + const foreign = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('foreign-load'), + name: 'skill', + arguments: { name: 'preset-only-skill' }, + agent: agentForCwd('/workspace/other'), + }) + expect(foreign.isError).toBe(true) + await scope.dispose() + }) + it('retains the last-good catalog while any provider discovery is incomplete', async () => { const home = await tempDir('tool-incomplete-catalog') const ctx = await setup(home) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d73737cfe4..0423fb8b89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,12 +134,36 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:* version: link:../../vendor/timer + '@deepseek-ai/dsh-agent-tool-mode': + specifier: workspace:^ + version: link:../../packages/core/agent-tool-mode '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot '@deepseek-ai/dsh-base': specifier: workspace:^ version: link:../../packages/bundle/base + '@deepseek-ai/dsh-client-ui-agent-preset': + specifier: workspace:^ + version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../packages/compact/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../packages/goal/command-goal + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../packages/goal/goal-session '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless @@ -149,30 +173,102 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-persona': + specifier: workspace:^ + version: link:../../packages/preset/persona + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../packages/plan/plan-mode '@deepseek-ai/dsh-pty': specifier: workspace:^ version: link:../../packages/pty/pty '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local + '@deepseek-ai/dsh-pwsh-sandbox': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-sandbox '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../packages/skill/skill-local + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../packages/tasks/tasks-local '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../packages/llm/token-meter '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../packages/interaction/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../packages/bash/tool-bash '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/self-modification/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/bash/tool-pwsh + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../packages/tasks/tool-tasks + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/tool-workflow '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../packages/workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../packages/context/workspace-context commander: specifier: ^15.0.0 version: 15.0.0 @@ -210,6 +306,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../packages/settings/settings '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -235,6 +334,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../vendor/group '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules @@ -843,6 +945,33 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/pwsh-sandbox: + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/tool-bash: dependencies: schemastery: @@ -937,6 +1066,12 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -955,6 +1090,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -965,6 +1103,9 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../../vendor/group '@cordisjs/plugin-hmr': specifier: workspace:^ version: link:../../../vendor/hmr @@ -1043,6 +1184,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../fs/fs-policy @@ -1073,6 +1217,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode + '@deepseek-ai/dsh-pwsh-sandbox': + specifier: workspace:^ + version: link:../../bash/pwsh-sandbox '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard @@ -1163,6 +1310,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../workflow/tool-ralph @@ -1261,6 +1411,9 @@ importers: packages/bundle/web-app: dependencies: + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -1279,6 +1432,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-agent-preset': + specifier: workspace:^ + version: link:../../client/ui-agent-preset '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../../client/ui-command @@ -1616,6 +1772,48 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-agent-preset: + 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-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-command: dependencies: clsx: @@ -2099,9 +2297,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-tool-ask-user': - specifier: workspace:^ - version: link:../../interaction/tool-ask-user clsx: specifier: ^2.0.0 version: 2.1.1 @@ -3024,6 +3219,37 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/core/agent-tool-mode: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/core/scope: devDependencies: '@deepseek-ai/dsh-invariants': @@ -4018,6 +4244,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4667,6 +4896,80 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/preset/agent-presets: + dependencies: + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../settings/settings-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/preset/persona: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': @@ -4840,12 +5143,18 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis packages/sandbox/sandbox-local: dependencies: + '@deepseek-ai/dsh-sandbox-windows-acl': + specifier: workspace:^ + version: link:../sandbox-windows-acl '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry @@ -4862,6 +5171,9 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../sandbox + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -4891,6 +5203,25 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/sandbox/sandbox-windows-acl: + dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../sandbox-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/scaffold/client: devDependencies: '@deepseek-ai/dsh-invariants': @@ -5654,6 +5985,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -7129,6 +7463,9 @@ importers: python/sdk-runtime: dependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../vendor/group '@cordisjs/plugin-include': specifier: workspace:^ version: link:../../vendor/include diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 1cf370661c..4b9e3b48c8 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,6 +5,7 @@ "private": true, "type": "module", "dependencies": { + "@cordisjs/plugin-group": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-timer": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 1c7bedc696..c38197c1e3 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -119,12 +119,16 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly<Record<string, readonly string[]>> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib. - '@deepseek-ai/dsh-base': ['cordis.patch.yml'], + // Profile bundles publish their dsh.bundle.patch layer beside the lib; + // dsh-base also ships the win32 shell platform layer the launcher reads. + '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], + // The argv-prefix runner entry ships beside the lib as its own bundle; + // sandbox-local resolves it through the package's ./runner export. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 2626cec2da..42d1d4c90b 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,11 +1,11 @@ { - "AGENTS.md": 1782, + "AGENTS.md": 1900, "docs/AGENTS.md": 1320, - "docs/architecture.md": 2174, + "docs/architecture.md": 2400, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 936 + "packages/README.md": 980 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 662ad1df13..745f44ed0a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -45,6 +45,7 @@ export { REGION_BEGIN, REGION_END } export const SERVICE_PAGE: Record<string, string> = { agentLoop: 'core.md', agentDefaultModel: 'core.md', + agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', bash: 'bash.md', @@ -346,6 +347,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = { SkillProvider: 'skills.md', SkillProviderObservation: 'skills.md', SkillRegistration: 'skills.md', + SkillViewOptions: 'skills.md', SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', SpillRef: 'spill.md', @@ -388,6 +390,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = { ToolExecutionResult: 'tools.md', ToolExecutionToken: 'tools.md', ToolGuard: 'tools.md', + ToolPresentationMode: 'tools.md', ToolRegistry: 'tools.md', ToolRestriction: 'tools.md', ToolSchema: 'tools.md', @@ -462,6 +465,9 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = { InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', + AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md', + AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', + PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md', BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 06b115ecee..26e9233f82 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -258,7 +258,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Human question/answer seam', mode: 'seam', consumers: ['tool-ask-user'], - note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', + note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, { key: 'planMode', @@ -267,6 +267,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.', }, + { + key: 'agentPresets', + pkg: 'agent-presets', + title: 'Per-session agent composition', + mode: 'core', + note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.', + }, { key: 'commands', pkg: 'commands', @@ -313,7 +320,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Default Agent model selection', mode: 'core', consumers: ['headless', 'host-apiproxy'], - note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner.', + note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', }, { key: 'agentLoop', @@ -675,7 +682,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) if (pluginName === '@deepseek-ai/dsh-acp-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) + lines.push(` ${appNode} --> ${nodeId('entrypoint', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) } lines.push( ` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index fccabef852..48d0c72db7 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1060,6 +1060,11 @@ "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/subsystems/skills.md", + "symbol": "SkillViewOptions", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/subsystems/skills.md", "symbol": "SkillProviderObservation", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 4c4d83ead6..b0aef8ddc9 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -78,6 +78,7 @@ for (const file of files) { errors.push(...validateExampleResolution()) errors.push(...validateAppResolution()) errors.push(...validateSourcePlaneResolution()) +errors.push(...validatePresetPlaneSeparation()) if (errors.length > 0) { console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') @@ -87,6 +88,76 @@ if (errors.length > 0) { console.log(`verify-cordis-config: ${files.length} config files passed.`) } +/** + * No shipped agent preset may repeat a row the host composition still runs. + * + * A preset contributes what ONE session adds to the host's registries. A row + * active on both planes is therefore mounted twice — once per process and once + * per session — and what that costs depends on what the row does: a provider + * behind an `isolate` realm shadows the host's for its own consumers, so a host + * contributor to that service reaches nobody; a row that registers into a host + * singleton registers once per live session, so the second one collides. + * + * Both have happened. `bash-env` in a preset realm left `DSH_WEB_URL` reaching + * no shell, and `tool-subagent-report` handed every child `report` once per live + * session until the second registration threw. Neither changes a tool catalog, + * so no catalog assertion can see them — and the shipped presets are near-copies + * of each other, so a fix applied to three of four is the normal failure. + * @returns one diagnostic per preset row that is also active on the host plane. + */ +function validatePresetPlaneSeparation(): string[] { + const problems: string[] = [] + // The shipped Web surface is two bundle patch layers over an empty root. + const hostFile = 'packages/bundle/base/cordis.patch.yml' + const overlayFile = 'packages/bundle/web-app/cordis.patch.yml' + const hostRows = rowIds(hostFile) + const overlay = loadEntries(overlayFile) + const disabled = new Set<string>() + for (const entry of overlay) { + if (!isRecord(entry)) continue + if (entry.disabled === true && typeof entry.id === 'string') disabled.add(entry.id) + } + // The overlay's own inserts are host-plane too; its disables take them back out. + const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id))) + for (const file of globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root })) { + for (const id of rowIds(file)) { + if (!active.has(id)) continue + problems.push( + `${file}: row "${id}" is also active in the host composition; ` + + 'a row belongs to exactly one plane', + ) + } + } + return problems +} + +/** Every entry of one config file, or an empty list when it is not an entry array. */ +function loadEntries(file: string): unknown[] { + const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + return isUnknownArray(document) ? document : [] +} + +/** + * Row ids declared anywhere in one config file, including inside group `config` + * lists — a preset nests most of its rows in `isolate` groups. + * @param file - repository-relative config path. + * @returns the declared ids. + */ +function rowIds(file: string): Set<string> { + const ids = new Set<string>() + const walk = (value: unknown): void => { + if (isUnknownArray(value)) { + for (const item of value) walk(item) + return + } + if (!isRecord(value)) return + if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id) + for (const child of Object.values(value)) walk(child) + } + walk(loadEntries(file)) + return ids +} + function validateEntry(value: unknown, file: string, path: string): void { if (!isRecord(value)) { errors.push(`${file}${path}: entry must be an object`) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 20e6c4bc14..6e69414745 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,8 +47,11 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/core/agent-tool-mode': { kind: 'indirect', reason: 'The row only selects between the two projections dsh-tools owns; it registers no prompt, schema, or result of its own.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, + 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/e2b/e2b': { kind: 'none', reason: 'The shared remote-runtime owner registers no model context; provider adapters and consumers own rendered effects.' }, @@ -104,6 +107,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/e2b/subprocess-e2b': { kind: 'indirect', reason: 'The remote spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, + 'packages/sandbox/sandbox-windows-acl': { kind: 'indirect', reason: 'The provider backend delegates model rendering to the bash/pwsh sandbox executors and their tools.' }, 'packages/scaffold/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/scaffold/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/scaffold/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index e404e3fc8b..d07f1d4ef9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -100,6 +100,7 @@ "./packages/feedback/*/src/invariant.ts", "./packages/guard/*/src/invariant.ts", "./packages/plan/*/src/invariant.ts", + "./packages/preset/*/src/invariant.ts", "./packages/subagent/*/src/invariant.ts", "./packages/tasks/*/src/invariant.ts", "./packages/workflow/*/src/invariant.ts", @@ -171,6 +172,7 @@ "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"], + "@deepseek-ai/dsh-client-ui-agent-preset": ["./packages/client/ui-agent-preset/src"], "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], @@ -208,6 +210,7 @@ "./packages/feedback/*/src", "./packages/guard/*/src", "./packages/plan/*/src", + "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/tasks/*/src", "./packages/workflow/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 9ce72753c1..632f6a84a7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -68,6 +68,7 @@ { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, + { "path": "./packages/client/ui-agent-preset" }, { "path": "./packages/client/ui-permission" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-question" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index f890487f3b..047dc5a1bc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,8 @@ "apps/web/tests/skill-user-invoke.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", + "apps/web/tests/agent-preset-selection.e2e.ts", + "apps/web/tests/agent-preset-authoring.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", @@ -152,6 +154,7 @@ { "path": "./packages/interaction/user-approval" }, { "path": "./packages/interaction/permission" }, { "path": "./packages/core/tools" }, + { "path": "./packages/core/agent-tool-mode" }, { "path": "./packages/skill/skill" }, { "path": "./packages/skill/skill-badge" }, { "path": "./packages/skill/skill-local" }, @@ -177,11 +180,13 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/bash-env" }, { "path": "./packages/bash/pwsh-local" }, + { "path": "./packages/bash/pwsh-sandbox" }, { "path": "./packages/bash/tool-pwsh" }, { "path": "./native/landlock-run/packages/entry" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, + { "path": "./packages/sandbox/sandbox-windows-acl" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, @@ -241,6 +246,8 @@ { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, + { "path": "./packages/preset/agent-presets" }, + { "path": "./packages/preset/persona" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/self-modification/tool-cordis" }, { "path": "./packages/self-modification/repository-plugin" }, diff --git a/vitest.config.ts b/vitest.config.ts index b6a75bf1d3..68bfafec8d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,6 +37,16 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] +// Windows-only packages: their sources execute exclusively on win32 (koffi +// loads Win32 libraries), so the Linux coverage lane can never cover them. +// The Windows dev/CI lane exercises them through the probe/runner suites; the +// per-file 100% gate must not fail on their Linux-uncovered paths. +const windowsOnlyCoverageExclusions = process.platform !== 'win32' + ? [ + 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + ] + : [] + // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh // (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -46,7 +56,10 @@ const windowsUnsupportedPackages = process.platform === 'win32' // narrower probe could exempt the file on hosts whose suites actually run. const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 ? [] - : ['packages/bash/pwsh-local/src/index.ts'] + : [ + 'packages/bash/pwsh-local/src/index.ts', + 'packages/bash/pwsh-sandbox/src/**/*.ts', + ] const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', @@ -215,6 +228,7 @@ export default defineConfig({ 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), + ...windowsOnlyCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome).