Merge remote-tracking branch 'origin/master' into worktree/windows-acl-hardening-followup

# Conflicts:
#	packages/sandbox/sandbox-windows-acl/src/index.ts
This commit is contained in:
Tianyi Cui
2026-08-10 21:50:09 +08:00
111 changed files with 2325 additions and 375 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both 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: f9c95176321496e965a95b6358d6feaa8466fe89
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 7d20c5a2662c9036382b30a96bc9973c8f0349bd
2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215
2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34

View File

@@ -27,8 +27,8 @@ Directories layer as follows:
- the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below
- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md)):
- **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table.
- **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else.
- **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.
- **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dsh.client` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else.
- **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 `dsh.client` 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 applications, 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 commands: `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.
@@ -40,7 +40,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch)
packages/host/* packages/client/*
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply,
webserver Web HTTP carriage client half = src/client/)
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
▼ │ (type-only + the client base class)
@@ -63,7 +63,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod
| Layer | Package | Responsibility | Key discipline |
|---|---|---|---|
| Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx |
| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dsh.client packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
| Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
| Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell |
| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy |

View File

@@ -25,8 +25,8 @@ Status: implemented
- 统一后端协议fetch、HTTP、流式接口等定义和支持见本篇「消息协议」起各节
- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有):
- **纯库**`ui-slots``web-react``ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。
- **静态到达 entry 包**`connection``runtime``ui-theme``i18n``hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
- **fetch 到达插件包**`ui-layout``ui-sidebar``ui-conversation``ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布tsdown 闭包工厂 bundle。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
- **静态到达 entry 包**`connection``runtime``ui-theme``i18n``hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
- **fetch 到达插件包**`ui-layout``ui-sidebar``ui-conversation``ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `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` = [直接使用核心 AgentSession 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。
@@ -38,7 +38,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch)
packages/host/* packages/client/*
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply,
webserver Web HTTP carriage client half = src/client/)
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
▼ │ (type-only + the client base class)
@@ -61,7 +61,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.
| 层 | 包 | 职责 | 关键纪律 |
|---|---|---|---|
| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/handler + 客户端基类) | 做简单、所有接入方都要Node/浏览器皆可 import协议内容见下文「消息协议」起各节client 不得经 ctx 绕开 api |
| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树host 级配置归属地defaults/persistenceRoot将来用户 profile | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树host 级配置归属地defaults/persistenceRoot将来用户 profile | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
| 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest元数据清单注入由 web 插件注册表供给) | Web浏览器访问专用零 workspace 依赖注册表经结构注入到达Electron 不复用它 |
| client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 |
| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹——见 Web 客户端架构笔记 | 双入口node 半边=空 apply实现在 `src/client/`);消费面唯一经 ApiProxy |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both 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-web-client-architecture.md
2026-07-19-gui-web-client-architecture.md: 82b2f85708c423748954644d4991e2d54d42874a
2026-07-19-gui-web-client-architecture.zh.md: c37252d1db291cae11db2a615c9e4005ece717da
2026-07-19-gui-web-client-architecture.md: bc61aab894d587820ef4cb568b6439993a27d30d
2026-07-19-gui-web-client-architecture.zh.md: 1f5bafe1dff878b5ca5ffcbdb9ed8ca38a863c9f

View File

@@ -30,7 +30,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon
## The client cordis tree and the loading chain
The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dsh.client` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.
@@ -108,7 +108,7 @@ Domain implementation files never import a sibling domain; shared surfaces route
## How to develop
- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
- **A new UI feature** = a new plugin package: declare `dsh.client` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
- **A new slot**: see the [slot system standard note](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally.
- **Consuming a new frame type**: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch.
- **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)).

View File

@@ -30,7 +30,7 @@ Status: implemented
## client cordis 树与装载链
装载链——两类包(普通包 vs dshClient 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules``packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包含基础设施都携带 `dshClient` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取而普通包react 家族、cordis、尚未升格的库保持打进壳、已播种、对图不可见bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVEFAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules``packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包含基础设施都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取而普通包react 家族、cordis、尚未升格的库保持打进壳、已播种、对图不可见bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVEFAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions``loader`)上对 cordis `Context` 做声明合并且服务不同client 包经纯类型子路径(`@deepseek-ai/dsh-session/types`消费协议词汇host 侧的声明合并不会搭车进入 client program。
@@ -108,7 +108,7 @@ src/client/
## 怎么开发
- **新 UI 功能** = 新插件包package.json 声明 `dshClient`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot无 host 逻辑时 node 半边保持空 apply用共享预设构建。把插件加进 host 配置manifest 与装载随之自动跟上。
- **新 UI 功能** = 新插件包package.json 声明 `dsh.client`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot无 host 逻辑时 node 半边保持空 apply用共享预设构建。把插件加进 host 配置manifest 与装载随之自动跟上。
- **新 slot**:见 [slot 体系标准笔记](2026-07-22-slot-type-chain-implementation.md)——约定合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
- **消费新帧类型**:纯传输 session frame → Session 分发 switchhost 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer不增加 Session 业务分支。
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props单组件私有滚动、搜索词、展开集→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store[slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both 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-23-client-plugin-loading-model.md
2026-07-23-client-plugin-loading-model.md: fff96f65a21d9527c8fa49589b178c490bacdd5a
2026-07-23-client-plugin-loading-model.zh.md: 0c0c95ba7ebffca33c2c2d1dec13f745c4316f43
2026-07-23-client-plugin-loading-model.md: 21289c5dcebc7244e98c602e9f10bac7eb365bc3
2026-07-23-client-plugin-loading-model.zh.md: c3c4ef598c1d92d4ebec7b9691d31cf33c7c62a2

View File

@@ -1,4 +1,4 @@
# Agent Note: Client plugin loading — plain packages, dshClient plugins, and the two-phase boot
# Agent Note: Client plugin loading — plain packages, dsh.client plugins, and the two-phase boot
Status: implemented
@@ -18,22 +18,22 @@ The lower layer supplies four capabilities: externals (the platform list), remot
Plugin bundles are built independently outside Vite's module graph. Feeding response text into an inline script leaves the browser with a dynamic source execution: no standard source-map chain connects the network resource, generated bundle, and TypeScript/TSX source, so performance profiles and stacks stop at generated `client.js`; the module system must also buffer the complete source and split one arrival responsibility across fetch and execute transport boundaries.
On top of that, client and host plugins register and load consistently: a package declares `dshClient` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides.
On top of that, client and host plugins register and load consistently: a package declares `dsh.client` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides.
The first-generation client loader (`createClientLoader`) hand-wrote both layers in one function. The fusion left no unload/reload path (loads were one-shot, style tags never removed), hand-copied dependency lists that had already drifted across three files, and a module-table backdoor for cross-plugin imports that duplicated cordis's service mechanism while making load order a correctness constraint. The structure below replaced it.
## Decision
### Two package kinds; `dshClient` means plugin, period
### Two package kinds; `dsh.client` means plugin, period
What makes a package a plugin? One rule: **a package is a plugin package once its consumption is cordis dependency injection; until then it is a plain package.** How code reaches the page is not part of the taxonomy — arrival follows from the kind instead of defining it.
- **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
- **Plugin packages** are everything else. Each one carries a `dshClient` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory.
- **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory.
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch.
To add a plugin package: declare `dshClient`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
To add a plugin package: declare `dsh.client`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
When does a plain package become a plugin? The upgrade law, recorded so the migration path stays honest: **a plain package becomes a plugin package when its consumers switch to cordis DI, not before.** Three promotions are queued: ui-slots (the slots machinery now living in runtime — SlotsService, the renderer contract, the root slot), web-react (the renderer install moving into its own `apply`), and ui-primitives (once components are served through slots/services). Until then they stay plain, and their symbol exports stay ordinary static imports.
@@ -67,10 +67,10 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
**Host side — compose the graph.**
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch loads the external script and registers its factory only. A single row's prefetch failure is swallowed here: phase two's import retries the load and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
@@ -86,7 +86,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
On the browser side, the driver reloads one plugin per frame, serialized:
@@ -113,7 +113,7 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber,
| `dsh-client-ui-slots` | slot registry core | plain, seeded | promote to plugin; receive runtime's slots machinery |
| `dsh-client-web-react` | ctx↔React glue | plain, seeded | promote to plugin; renderer install moves into its apply |
| `dsh-client-ui-primitives` | base components | plain, seeded | promote to plugin (components via slots/services) |
| `dsh-client-connection` | wire layer | plugin (dshClient + bundle), declares `immediately` | transport swap (Electron IPC carrier) |
| `dsh-client-connection` | wire layer | plugin (`dsh.client` + bundle), declares `immediately` | transport swap (Electron IPC carrier) |
| `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer |
| `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) |
| `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition |
@@ -132,7 +132,7 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi
| Rejected | One-line reason |
|---|---|
| Two-axis taxonomy (entry × arrival) with non-dshClient infrastructure packages | Erased manifest dependency edges (inject leaked to the composer), split the plugin shape in two, blinded the purity gate to half the plugins |
| Two-axis taxonomy (entry × arrival) with infrastructure packages lacking `dsh.client` | Erased manifest dependency edges (inject leaked to the composer), split the plugin shape in two, blinded the purity gate to half the plugins |
| Keep evolving the hand-written loader into a governor | Re-implements entry/fiber lifecycle the vendored Loader owns; HMR would have no shared skeleton with the host side |
| Reuse `@cordisjs/plugin-hmr` in the browser | ~80% solves problems the browser doesn't have (fs watching, deep graph coloring, Node's dual caches); the reload skeleton is copied as a shape |
| Module federation | Independently built remote bundles are exactly the form vite federation does not support |

View File

@@ -1,4 +1,4 @@
# Agent Note: client 插件装载——普通包、dshClient 插件与双阶段 boot
# Agent Note: client 插件装载——普通包、dsh.client 插件与双阶段 boot
Status: implemented
@@ -18,22 +18,22 @@ host 侧cordis 插件装载站在 Node 的模块机制之上——require cac
插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script浏览器只能看到一次动态源码执行网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输边界。
在此之上client 与 host 插件以一致的方式注册与装载:包声明一次 `dshClient`host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。
在此之上client 与 host 插件以一致的方式注册与装载:包声明一次 `dsh.client`host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。
第一代 client loader`createClientLoader`)把这两层手写进了同一个函数。这一融合留下的是:没有卸载/重载路径装载一次性style 标签从不移除)、在三个文件间人肉抄写且早已漂移的依赖清单、一条供跨插件 import 走的模块表后门——既复制了 cordis 的服务机制,又把装载顺序变成正确性约束。下文的结构取代了它。
## Decision
### 两类包;`dshClient` 即插件,别无他义
### 两类包;`dsh.client` 即插件,别无他义
什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别。
- **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库react 家族、cordis、`@deepseek-ai/dsh-client-modules`模块系统本身——它永远不可能是插件因为模块先于一切模块、web 壳内核以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
- **插件包**是其余一切。每个都携带 `dshClient` manifest元数据清单声明`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js``exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括connection、runtime、ui-theme、i18n、hmr仅进 dev 图、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。
- **插件包**是其余一切。每个都携带 `dsh.client` manifest元数据清单声明`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js``exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括connection、runtime、ui-theme、i18n、hmr仅进 dev 图、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。
manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy。负责组合的 app 只拥有名册与 `--dev` 开关。
新增一个插件包:声明 `dshClient`,经共享预设产出 `./client` bundle把包名加进负责组合的 app 的名册。除此之外无需任何交接。
新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle把包名加进负责组合的 app 的名册。除此之外无需任何交接。
普通包何时升格为插件?升级法则,记录在案让迁移路径保持诚实:**普通包在其消费方改用 cordis DI 之时升格为插件包,绝不提前。**三项升格在排队ui-slots现居 runtime 的 slots 机件——SlotsService、渲染器约定、root slot、web-react渲染器安装移入自己的 `apply`、ui-primitives组件经 slot/服务供给之时)。在那之前它们保持普通包身份,符号导出保持普通的静态 import。
@@ -67,10 +67,10 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
**host 侧——组合这张图。**
1. 负责组合的 app`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack[host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下畸形声明字段同样会让激活失败host 检查会从 FAILED fiber 报告这两类错误。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下畸形声明字段同样会让激活失败host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries包元数据含「非 client 包」的否定结论按名永久缓存bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知它是朴素路由注册插件bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里不代表这次部署要挂载它扫描发现无从替人做这个决定node 半只扫描配置树实际挂载了的东西。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dsh.client 包存在于仓库里不代表这次部署要挂载它扫描发现无从替人做这个决定node 半只扫描配置树实际挂载了的东西。
**第一阶段——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即加载外部脚本,只登记工厂。单行预取失败在这里被吞下:第二阶段 import 时会重试加载并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障不是身份。包声明它注册表把它带进图行。基础设施插件connection、runtime、ui-theme、i18n外加 hmr声明它UI 插件则径直按需到达。
@@ -86,7 +86,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
热重载是否启用是一项组合决策dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSEServer-Sent Events通道prod 组合不挂载,两者皆无。
重建好的 bundle 怎么变成重载信号hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire永不进会话日志。轮询是刻意选择inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500msdispose资源释放会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
重建好的 bundle 怎么变成重载信号hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire永不进会话日志。轮询是刻意选择inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500msdispose资源释放会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
浏览器侧,驱动插件每帧重载一个插件,串行执行:
@@ -113,7 +113,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
| `dsh-client-ui-slots` | slot 注册表核心 | 普通包,已播种 | 升格为插件;接收 runtime 的 slots 机件 |
| `dsh-client-web-react` | ctx↔React 胶水 | 普通包,已播种 | 升格为插件;渲染器安装移入其 apply |
| `dsh-client-ui-primitives` | 基础组件 | 普通包,已播种 | 升格为插件(组件经 slot/服务供给) |
| `dsh-client-connection` | wire 层 | 插件dshClient + bundle声明 `immediately` | 传输替换Electron IPC 载体) |
| `dsh-client-connection` | wire 层 | 插件dsh.client + bundle声明 `immediately` | 传输替换Electron IPC 载体) |
| `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry另行裁定 |
| `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 |
@@ -132,7 +132,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模
| Rejected | One-line reason |
|---|---|
| 两轴分类体系entry × 到达),基础设施包不带 dshClient | 抹掉了 manifest 依赖边inject 泄漏给组合方)、把插件形态拆成两种、让纯度门禁对一半插件失明 |
| 两轴分类体系entry × 到达),基础设施包不带 dsh.client | 抹掉了 manifest 依赖边inject 泄漏给组合方)、把插件形态拆成两种、让纯度门禁对一半插件失明 |
| 继续把手写 loader 演化成治理器 | 重新实现 vendored Loader 已拥有的 entry/fiber 生命周期HMR 将与 host 侧毫无共享骨架 |
| 在浏览器复用 `@cordisjs/plugin-hmr` | 约 80% 在解决浏览器没有的问题fs 监听、深度图着色、Node 的双缓存);只按形状抄用其重载骨架 |
| 模块联邦module federation | 独立构建的远端 bundle 恰是 vite 联邦不支持的形态 |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both 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: 9bf44e398da66ee286fc9bbc1496c002606d1606
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 23d9bc790c792438cb699952ee802e1ffa89de88
2026-07-24-web-config-tree-boot-and-transport-layering.md: c00d0c544cfd04927d23eac53720cb969a44e044
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 4fe315bb7673ba219286b176123ccbbe08f02f0d

View File

@@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
## Decision
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
**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 1025% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.

View File

@@ -12,7 +12,7 @@ Status: implemented
## 决策
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 rostermodules 行同时是 host 行)。不做 spine bundle每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay[共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer而不会让一棵配置树横跨两个 Loader 实现。
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 rostermodules 行同时是 host 行)。不做 spine bundle每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay[共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer而不会让一棵配置树横跨两个 Loader 实现。
**boot 胶水由两个类组成。** `AppCLIEntry`apps/cli`AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西argv 事实、合成的 patch 集、解析出的 boot manifest元数据清单、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 envambient > 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 层工厂全部注册完——否则有实测 1025% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both 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: e6a96ac62f69a3bd312f61cc920caa259d2dc5b0
2026-07-24-web-session-model-selector.zh.md: 5a0245359d69ac6e59a20dc3276b9411c4b25e23
2026-07-24-web-session-model-selector.md: 3f7dde359842a82d03218dc71ca0e3617ae6dbdf
2026-07-24-web-session-model-selector.zh.md: 474476966da8d4628578dc3e13f74f643bd6eaed

View File

@@ -18,7 +18,7 @@ The browser `ModelService` owns one `ModelDirectory` per live session. Its snaps
`@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current selection is absent from the groups, the trigger displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model assigns the complete selection through the shared selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default.
The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant.
The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dsh.client` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant.
## Alternatives considered

View File

@@ -18,7 +18,7 @@ Web Host 为每个新建或恢复的 Agent 安装 `ModelSelection`。如果会
`@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型的名称与生效的推理强度标签。当前选择不在分组中时,触发器显示 `Select model`模型列表不标记任何活动行Effort 行也保持隐藏;选择一个已列出的模型,会通过共享的选择路径赋值完整选择。除此情形外,向上展开的菜单会首先提供 Model 与 EffortModel 可深入提供方分组Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。
生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest元数据清单将加载顺序置于运行时与命令功能之后Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。
生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dsh.client` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest元数据清单将加载顺序置于运行时与命令功能之后Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。
## 考虑过的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: 6a62fddb79670c3ab4cc0446796dbffd7130aed9
2026-08-08-native-windows-pull-request-ci.zh.md: 990b8ed1434934337b8ff20c5f3be2c03cd9c61b
2026-08-08-native-windows-pull-request-ci.md: 33fbf1ae378112b4fd82633a77afa52056d93d98
2026-08-08-native-windows-pull-request-ci.zh.md: 552e5cd3129011198fe442ba747cf2fdb7d97365

View File

@@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name
The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage.
The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform.
The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.

View File

@@ -18,7 +18,7 @@ Status: implemented
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。
16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture系统会使用显式的有界等待而不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。
16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture系统会使用显式的有界等待而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/api-gateway.md
api-gateway.md: 4b904f24e5755460b6c629e3d2fd43ffc1aefaaf
api-gateway.zh.md: 1e434cbb99450d241d6fde7e570ae1fadf5c0209
api-gateway.md: 81cd80893d53212edc74cc85e3e05731fa05f411
api-gateway.zh.md: 692cf825f619f71e86ae801e04246e9feb4a4c36

View File

@@ -151,7 +151,7 @@ pnpm run dsh -- web --dev
pnpm run dev:web
```
`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dshClient` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS.
`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS.
Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution:

View File

@@ -151,7 +151,7 @@ pnpm run dsh -- web --dev
pnpm run dev:web
```
`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dshClient` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator也不会生成 Remote Client DTS。
`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator也不会生成 Remote Client DTS。
只修改 Remote 方法实现体而不改变约定时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献:

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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: 85aee35af0e3de4d7cdbb715bc60c832c023d5ac
capability-seams.zh.md: d3664b649f36b1788c6633497c95d5731ad4a401
capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106
capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642

View File

@@ -437,7 +437,7 @@ flowchart LR
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). |
| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. |
| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. |
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. |
Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard.

View File

@@ -439,7 +439,7 @@ flowchart LR
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本并返回面向模型的定位信息和取回提示spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 |
| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native``directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot不通过协议发布。 |
| `ctx.httpServer` | `core` | `webserver` | - | `connection``modules``hmr` | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap以及静态 dist 回退Web 传输插件注册自己的路由。 |
| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | 通过增量 dshClient 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 |
| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 |
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow)、[`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 |
维护模式:混合模式。服务从 Cordis 声明中发现;接口、实现和消费方角色在 `scripts/gen-doc-graphs.ts` 中分类,并设有完整性守卫。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md
adding-a-package.md: 9e82e6d00177768a6368d1cd9740afa585543171
adding-a-package.zh.md: 072df33138a1ead20c497cebd8e4aa960c2d1fc8
adding-a-package.md: dcd5fa66f3616c2c22930babd09cb3edab38e182
adding-a-package.zh.md: c8769197e0b1db31348b7f2442dbcd636bf43cb2

View File

@@ -34,7 +34,7 @@ In-package relative imports use explicit `.ts` specifiers in source (for example
| `tsconfig.host.json` (Host package) or `tsconfig.client.json` (Client package) | add `{ "path": "./packages/<group>/<pkg>" }` to `references` — an ordinary package belongs to exactly one aggregate, never both. `api/remotes` uses a repository-specific split because the Host generates a contract that the Client consumes in a later phase; new packages must not copy it ([layout](../development.md#typescript-project-layout)) |
| `knip.json` | only if the package has entrypoints that repository discovery does not already cover |
A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract.
A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dsh.client` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract.
Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`.

View File

@@ -34,7 +34,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c
| `tsconfig.host.json`Host 包)或 `tsconfig.client.json`Client 包) | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }`——普通包恰好属于一个 aggregate绝不两个都加。`api/remotes` 因 Host 生成约定与 Client 消费约定之间存在顺序依赖而使用仓库专属拆分,新增包不得仿照([布局](../development.md#typescript-project-layout) |
| `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 |
`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset`packages/client/tsdown.client.ts`——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。
`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`client 插件包还需在 package.json 声明 `dsh.client`、导出 `./client`、调用共享 tsdown preset`packages/client/tsdown.client.ts`——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts``tsdown.config.ts``.oxlintrc.json``scripts/check-workspace-constraints.ts`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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/README.md
README.md: 7d66cfcf66ffb0bed9430892308934c1f10982f4
README.zh.md: 90e2b28b15870387539500568bb85b525db63ef6
README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f
README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b

View File

@@ -45,7 +45,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [http-server.md](http-server.md) | the HTTP carrier: `WebRouteKind`/`WebRoute`, match order, the claimable fallback seat, index taps |
| [storage.md](storage.md) | the storage subsystem: the backend contract (`StorageBackend`), `StorageForms`, `DomainSpec`/`Domain`, `domain/changed` |
| [workspace.md](workspace.md) | the workspace registry: `Workspace`/`WorkspaceId`, registration and resolution, the session `cwd` relationship |
| [client-modules.md](client-modules.md) | the web plugin table: `dshClient` declarations, `WebBootGraph` wire composition, the bundle route and index tap |
| [client-modules.md](client-modules.md) | the web plugin table: `dsh.client` declarations, `WebBootGraph` wire composition, the bundle route and index tap |
| [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed |
| [telemetry.md](telemetry.md) | the outbound session-reporting capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, and the `telemetry/record` redact waterfall |

View File

@@ -45,7 +45,7 @@
| [http-server.md](http-server.md) | HTTP 载体:`WebRouteKind`/`WebRoute`、匹配顺序、可认领的回退席位、index 转换 |
| [storage.md](storage.md) | 存储子系统:后端约定(`StorageBackend`)、`StorageForms``DomainSpec`/`Domain``domain/changed` |
| [workspace.md](workspace.md) | 工作区注册表:`Workspace`/`WorkspaceId`、注册与解析、与会话 `cwd` 的关系 |
| [client-modules.md](client-modules.md) | Web 插件表:`dshClient` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 |
| [client-modules.md](client-modules.md) | Web 插件表:`dsh.client` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 |
| [session-projection.md](session-projection.md) | 投影 seam`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 |
| [telemetry.md](telemetry.md) | 对外会话上报能力 seam`TelemetryRecord`/`TelemetrySeverity``TelemetryBackend` 约定和 `telemetry/record` 脱敏 waterfall |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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/client-modules.md
client-modules.md: 808fa62b2e5a4351b1dc98bbe4f3d2af63c9dd2b
client-modules.zh.md: dcab60bea7c54770cc5e6032afb39d6dd189d037
client-modules.md: 14fa5b2ca94c109a8c6bbe2ce09bde73a4448769
client-modules.zh.md: 7c4df1689dc8df27f7e394763e3bde4320d5c632

View File

@@ -2,7 +2,7 @@
English | [中文](client-modules.zh.md)
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModuleHost` (`ClientModuleHostService`). It scans the host Loader's entries for `dshClient` packages, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and taps the index render to inject the boot manifest — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [http-server.md](http-server.md) supplies the prefix route and index tap this service registers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModuleHost` (`ClientModuleHostService`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and taps the index render to inject the boot manifest — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [http-server.md](http-server.md) supplies the prefix route and index tap this service registers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
@@ -15,7 +15,7 @@ The graph is the wire single source between the Node and browser halves: the hos
* One composed client entry pushed by the host (a graph row). Wire
* single source: the host node half (package root) produces this same shape.
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* metadata (the authoritative edges live in each package's `dsh.client`
* declaration and reach fibers through entry creation).
*/
interface WebBootEntry {
@@ -46,7 +46,7 @@ Each row's `rev` is the bundle's content hash and rides the URL as a cache-busti
## The scan
A package joins the table by declaring `dshClient` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Package resolution anchors at the config tree's `ctx.baseUrl` — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset.
A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Package resolution anchors at the config tree's `ctx.baseUrl` — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset.
Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.
@@ -74,7 +74,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModuleHost` — `ClientModuleHostService`
The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**

View File

@@ -2,7 +2,7 @@
[English](client-modules.md) | 中文
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModuleHost``ClientModuleHostService`)形式提供。它扫描宿主 Loader 的 entry 找出 `dshClient`package组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle并经 index 转换index tap注入启动 manifest元数据清单——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop智能体循环主干并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[http-server.md](http-server.md) 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModuleHost``ClientModuleHostService`)形式提供。它扫描宿主 Loader 的 entry找出声明了 `dsh.client` package组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle并经 index 转换index tap注入启动 manifest元数据清单——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop智能体循环主干并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[http-server.md](http-server.md) 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
@@ -15,7 +15,7 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client
* One composed client entry pushed by the host (a graph row). Wire
* single source: the host node half (package root) produces this same shape.
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* metadata (the authoritative edges live in each package's `dsh.client`
* declaration and reach fibers through entry creation).
*/
interface WebBootEntry {
@@ -46,7 +46,7 @@ interface WebBootGraph {
## 扫描
包加入这张表的方式,是在自己的 package.json 中声明 `dshClient``platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。包解析锚定在配置树的 `ctx.baseUrl`——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错。
包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client``platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。包解析锚定在配置树的 `ctx.baseUrl`——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错。
扫描是单包增量的不存在全量重扫代码路径。fiber 构造或 dispose资源释放时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush因此初扫与稳态共享一条实现——但失败姿态相反。激活时已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。
@@ -74,7 +74,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModuleHost` — `ClientModuleHostService`
The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**

View File

@@ -26,13 +26,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web",
"immediately": true
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,12 +22,14 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -51,14 +51,13 @@ export interface DshProfileManifest {
}
/**
* The `dsh`-owned manifest section of a package.json. The nested key names
* the manifest kind: a bundle package declares `bundle`, a profile directory
* declares `profile`; nothing declares both.
* The profile-launcher slice of the `dsh`-owned package.json section. A
* manifest may declare both roles; other consumers own additional keys.
*/
export interface DshManifestSection {
/** Present on bundle packages only. */
/** Bundle metadata consumed by the profile launcher. */
bundle?: DshBundleManifest
/** Present on profile manifests only. */
/** Profile metadata consumed by the profile launcher. */
profile?: DshProfileManifest
}

View File

@@ -37,7 +37,7 @@
# ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into
# `dsh.client` rows are the browser roster the modules node half scans into
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
- insert:
- id: code-runtime
@@ -101,9 +101,9 @@
printUrl: true
surfaceContext: true
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
# Dual-face: node half scans this very tree for dshClient rows, composes
# Dual-face: node half scans this very tree for dsh.client rows, composes
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
# module table the shell kernel constructs before cordis exists (adopted
# as a plugin entry by the kernel, never fetched).

View File

@@ -91,9 +91,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-question are minimal skeletons):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.

View File

@@ -22,10 +22,12 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [],
"platform": "web",
"immediately": true
}
},
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -22,10 +22,12 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [],
"platform": "web",
"immediately": true
}
},
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
}
},
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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/modules/README.md
README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace
README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f
README.md: a1d578850c2518a85dc32f048768b78caf5ffec4
README.zh.md: 772a4870f7ef6730d9d3d4db434ed771d97984f0

View File

@@ -8,7 +8,7 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
## Model Experience

View File

@@ -8,7 +8,7 @@
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory并发调用共享一个进行中的任务`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -22,10 +22,12 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"platform": "web",
"inject": [],
"immediately": true
"dsh": {
"client": {
"platform": "web",
"inject": [],
"immediately": true
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -44,7 +44,7 @@ declare module 'cordis' {
* One composed client entry pushed by the host (a graph row). Wire
* single source: the host node half (package root) produces this same shape.
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* metadata (the authoritative edges live in each package's `dsh.client`
* declaration and reach fibers through entry creation).
*/
export interface WebBootEntry {

View File

@@ -1,6 +1,6 @@
/**
* Node half of the client module system (dshClient dual-face package): scans
* the host Loader's entries for `dshClient` packages, composes the
* Node half of the client module system (`dsh.client` dual-face package): scans
* the host Loader's entries for packages declaring `dsh.client`, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
* map, taps the index render to inject the boot manifest, and provides the
@@ -43,7 +43,7 @@ declare module 'cordis' {
}
}
/** package.json `dshClient` declaration fields, validated one by one after reading the file. */
/** package.json `dsh.client` declaration fields, validated one by one after reading the file. */
interface DshClientDeclaration {
inject?: string[]
platform: string
@@ -51,7 +51,7 @@ interface DshClientDeclaration {
immediately?: boolean
}
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
interface PkgMeta {
clientPath: string
inject?: string[]
@@ -105,21 +105,21 @@ interface WebPluginRecord {
clientPath: string
}
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
if (value === undefined) return undefined
if (typeof value !== 'object' || value === null) {
throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
}
const decl = value as Record<string, unknown>
if (typeof decl.platform !== 'string') {
throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`)
throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
}
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`)
throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`)
}
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`)
throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
}
return {
platform: decl.platform,
@@ -175,7 +175,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
}
/**
* The web plugin table service: incremental dshClient scan + wire composition
* The web plugin table service: incremental `dsh.client` scan + wire composition
* + bundle route + index tap. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
@@ -186,7 +186,7 @@ export class ClientModuleHostService extends Service {
private readonly table = new Map<string, WebPluginRecord>()
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
// subpath rows — or a package without a web dshClient declaration) are
// subpath rows — or a package without a web `dsh.client` declaration) are
// cached as null and never expire: plugin-set changes take effect on restart.
private readonly pkgMeta = new Map<string, PkgMeta | null>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
@@ -342,14 +342,18 @@ export class ClientModuleHostService extends Service {
return null
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
const decl = parseDshClient(pkgName, pkg.dshClient)
const dsh = pkg.dsh
const decl = parseDshClient(
pkgName,
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
)
if (decl === undefined || decl.platform !== 'web') {
this.pkgMeta.set(pkgName, null)
return null
}
const clientRel = clientExportOf(pkgName, pkg.exports)
if (clientRel === undefined) {
throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`)
throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
}
const meta: PkgMeta = {
clientPath: join(dirname(pkgPath), clientRel),

View File

@@ -17,8 +17,11 @@ afterEach(() => {
root = undefined
})
/** Create a resolvable dshClient package whose client export points at the returned path. */
function writePackage(packageName: string): string {
/** Create a resolvable package whose client export points at the returned path. */
function writePackage(
packageName: string,
metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
): string {
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
const clientPath = join(pkgRoot, 'lib', 'client.js')
@@ -29,7 +32,7 @@ function writePackage(packageName: string): string {
'./client': './lib/client.js',
'./package.json': './package.json',
},
dshClient: { platform: 'web' },
...metadata,
}))
return clientPath
}
@@ -66,6 +69,20 @@ function construct(packageNames: string[]): ClientModuleHostService {
}
describe('client bundle activation', () => {
it('allows sibling dsh roles', () => {
const currentName = '@fixture/current-client-field'
const clientPath = writePackage(currentName, {
dsh: {
bundle: { patch: './cordis.patch.yml' },
client: { platform: 'web' },
profile: { bundles: [] },
},
})
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
})
it('groups missing bundles under one source-build instruction with a package/path list', () => {
const firstName = '@fixture/missing-first'
const secondName = '@fixture/missing-second'

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry"
],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry"
],
"platform": "web",
"immediately": true
}
},
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -1,4 +1,4 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent 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/test-runtime/README.md
README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0
README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310
README.md: d5c0797c37168578f08a08f3d5d57670d7973db0
README.zh.md: 57854213c3a9ea28850665eb26d07bc824c017e8

View File

@@ -8,7 +8,7 @@ The doubles implement the same outward faces features receive through ctx (`Test
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3``frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
Not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only.
## Model Experience

View File

@@ -8,7 +8,7 @@
局部 DOM 快照:`declare(children)` 注册自动 frame逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3``frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)``mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
不属于产品插件图(无 `dshClient`feature 包仅以 `devDependencies` 依赖之。
不属于产品插件图(无 `dsh.client`feature 包仅以 `devDependencies` 依赖之。
## 模型体验

View File

@@ -5,7 +5,7 @@
* declaration, registration, scope, store, inject, rendering, updates, and
* disposal without hand-building the machinery per suite.
*
* Not part of the product plugin graph (no `dshClient`); feature packages
* Not part of the product plugin graph (no `dsh.client`); feature packages
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
* machinery — everything mounts the production implementations.
* @module @deepseek-ai/dsh-client-test-runtime

View File

@@ -22,15 +22,17 @@
"./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"
"dsh": {
"client": {
"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",

View File

@@ -2,7 +2,7 @@
* 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.
* package.json dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */

View File

@@ -22,14 +22,16 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -1,7 +1,7 @@
/**
* Command UI plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader; the browser half ships
* via exports["./client"], discovered through the package.json dshClient
* via exports["./client"], discovered through the package.json dsh.client
* declaration. The host command registry itself mounts separately
* (bootHost + CommandService).
*/

View File

@@ -22,14 +22,16 @@
"./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-layout"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -1,7 +1,7 @@
/**
* Deliverables plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader; the browser half ships
* via exports["./client"], discovered through the package.json dshClient
* via exports["./client"], discovered through the package.json dsh.client
* declaration.
*/

View File

@@ -22,14 +22,16 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -2,7 +2,7 @@
* Goal surface plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader; the browser half
* ships via exports["./client"], discovered through the package.json
* dshClient declaration.
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */

View File

@@ -22,12 +22,14 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -2,7 +2,7 @@
* Model selection plugin, node half. Pure UI plugin: the empty apply exists
* so the plugin appears in the host cordis.yml / Loader; the browser half
* ships via exports["./client"], discovered through the package.json
* dshClient declaration.
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,14 +22,16 @@
"./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-command"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -2,7 +2,7 @@
* Permission surfaces plugin, node half. The empty apply exists so the plugin
* appears in the host cordis.yml / Loader; the browser half ships the
* new-session Settings row and current-session command picker through
* exports["./client"], discovered from the package.json dshClient declaration.
* exports["./client"], discovered from the package.json dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -1,7 +1,7 @@
/**
* Plan control plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader; the browser half ships
* via exports["./client"], discovered through the package.json dshClient
* via exports["./client"], discovered through the package.json dsh.client
* declaration. Plan behavior itself (the /plan command, the plan projection
* unit, the policy section) is owned by `@deepseek-ai/dsh-plan-mode`,
* composed independently on the host roster.

View File

@@ -22,12 +22,14 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,14 +22,16 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,12 +22,14 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -27,7 +27,7 @@ export type {
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-sidebar's apply, whose activation order relative to this one is NOT
* constrained (dshClient.inject edges are informational); registration
* constrained (dsh.client.inject edges are informational); registration
* depends on the slot through `slots.inject()`.
*/
export const inject = ['slots']

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,14 +22,16 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -2,7 +2,7 @@
* Skill reference plugin, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dshClient declaration.
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this source plugin. */

View File

@@ -22,12 +22,14 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -1,7 +1,7 @@
/**
* Slash trigger plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader; the browser half ships
* via exports["./client"], discovered through the package.json dshClient
* via exports["./client"], discovered through the package.json dsh.client
* declaration.
*/

View File

@@ -22,15 +22,17 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -2,7 +2,7 @@
* Subagent reference plugin, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dshClient declaration.
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this source plugin. */

View File

@@ -23,14 +23,16 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web",
"immediately": true
}
},
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -22,13 +22,15 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,12 +22,14 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -22,14 +22,16 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",

View File

@@ -37,7 +37,7 @@ const NS = 'workspace'
/**
* Required services (cordis fiber inject). The target slots are declared by
* the ui-sidebar / ui-conversation applies, whose activation order relative
* to this one is NOT constrained: dshClient.inject edges are informational
* to this one is NOT constrained: dsh.client.inject edges are informational
* (loading/prefetch metadata, never apply sequencing) and neither owner
* provides a waitable service. apply therefore depends on each slot
* declaration through `slots.inject()` instead of assuming order.

View File

@@ -2,7 +2,7 @@
* Workspace picker plugin, node half. Pure UI plugin: the empty apply exists
* so the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration).
* through the package.json dsh.client declaration).
*/
/** Host plugin body — no host-side behavior for the workspace picker plugin. */

View File

@@ -56,12 +56,14 @@
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
}
}

View File

@@ -57,11 +57,13 @@
"react": "^18.2.0",
"tsx": "^4.19.2"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
}
}
}

View File

@@ -105,8 +105,7 @@ async function tightenModes(dir: string): Promise<void> {
if (entry.isDirectory()) {
await tightenModes(target)
} else {
/* v8 ignore next -- Windows mode bits cannot represent POSIX owner-execute state;
* the Windows native gate preserves the DACL while the POSIX suite covers this branch. */
/* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */
await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700)
}
}

View File

@@ -168,12 +168,14 @@ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', {
dwThreadId: 'uint32',
})
/* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */
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}`)
}
/* v8 ignore stop */
/**
* Allocate one pointer-sized slot (for `T **` out-parameters).

View File

@@ -408,6 +408,8 @@ export class AclSandbox {
freeSidBestEffort(api, sidPtr, label, failures)
}
const token = this.token
/* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always
has its token; the guard mirrors the write-SID guard. */
if (token !== undefined) {
try {
if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token')

View File

@@ -0,0 +1,456 @@
/**
* ACL failure-path tests with stub binding tables (the failure-paths.spec.ts
* pattern): every checked Win32 call in the lock, read-merge-write, and
* grant-skip sequence has a failing counterpart, and each failure closes the
* handles it created before throwing. The exact-ACE skip and the DACL-walk
* defenses are driven through crafted in-memory ACL/SID buffers. Pure
* stubs — no real Win32 calls, so these run on every platform; the
* real-FFI round-trip lives in acl.spec.ts (win32 only).
*/
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts'
import { allocBytes, ptrAddress } from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import { Win32Error } from '../src/errors.ts'
import * as abi from '../src/win32-abi.ts'
const PVOID = koffi.pointer('void')
/** The stub the grant/revoke happy path needs; every call succeeds until a field is overridden per test. */
function aclApi(overrides: Partial<Win32Bindings> = {}): Win32Bindings {
return {
getTempPathW: vi.fn((_length: number, buffer: Buffer) => {
const temp = tmpdir().replace(/[\\/]$/u, '')
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,
) => {
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 as NativePtr),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
...overrides,
} as unknown as Win32Bindings
}
/** One SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes), subauthorities@8. */
function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 5]): NativePtr {
const sid = allocBytes(8)
koffi.encode(sid, 'uint8', revision)
koffi.encode(sid, 1, 'uint8', count)
authority.forEach((byte, index) => {
koffi.encode(sid, 2 + index, 'uint8', byte)
})
return sid
}
/**
* One in-memory ACL carrying the exact grant ACE the skip checks for:
* header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE
* (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects
* whether the inline SID bytes equal `sid`.
*/
function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr {
const acl = allocBytes(32)
koffi.encode(acl, 'uint8', 2) // AclRevision
koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE
koffi.encode(acl, 4, 'uint16', 1) // AceCount
const ace = 8
koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE)
koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT)
koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID
koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK)
const inlineSid = ace + 8
for (let offset = 0; offset < 8; offset++) {
koffi.encode(acl, inlineSid + offset, 'uint8', match
? koffi.decode(sid, offset, 'uint8') as number
: offset === 0 ? 9 : 0)
}
return acl
}
describe('withPathLock failure paths', () => {
it('fails closed when CreateFileW returns an invalid handle', () => {
const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) })
let caught: unknown
try {
withPathLock(api, 'C:\\locked', () => {})
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CreateFileW')
})
it('closes the handle and reports when LockFileEx fails', () => {
const closeHandle = vi.fn(() => 1)
const api = aclApi({ lockFileEx: vi.fn(() => 0), closeHandle })
let caught: unknown
try {
withPathLock(api, 'C:\\locked', () => {})
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('LockFileEx')
expect(closeHandle).toHaveBeenCalledWith(7n)
})
it('closes the handle and reports when UnlockFileEx fails', () => {
const closeHandle = vi.fn(() => 1)
const api = aclApi({ unlockFileEx: vi.fn(() => 0), closeHandle })
let caught: unknown
try {
withPathLock(api, 'C:\\locked', () => {})
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('UnlockFileEx')
expect(closeHandle).toHaveBeenCalledWith(7n)
})
it('reports a failed CloseHandle after a successful action', () => {
const api = aclApi({ closeHandle: vi.fn(() => 0) })
let caught: unknown
try {
withPathLock(api, 'C:\\locked', () => {})
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CloseHandle')
})
})
describe('mergeAndApply failure paths', () => {
it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => {
const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none
const sid = craftSid(1, 0)
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
})
it('reports a NULL merged ACL when there is no descriptor to free', () => {
const api = aclApi({ setEntriesInAclW: vi.fn(() => 0) }) // no out slot write, no descriptor
const sid = craftSid(1, 0)
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
})
it('frees the descriptor and reports when SetEntriesInAclW fails', () => {
const localFree = vi.fn(() => 0n as NativePtr)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, 0n)
koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL
return 0
}),
setEntriesInAclW: vi.fn(() => 5),
localFree,
})
const sid = craftSid(1, 0)
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
expect(localFree).toHaveBeenCalledWith(6n)
})
it('frees the descriptor and reports a NULL merged ACL', () => {
const localFree = vi.fn(() => 0n as NativePtr)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, 0n)
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot
localFree,
})
const sid = craftSid(1, 0)
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
expect(localFree).toHaveBeenCalledWith(6n)
})
it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => {
const localFree = vi.fn(() => 0n as NativePtr)
const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree })
const sid = craftSid(1, 0)
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW')
expect(localFree).toHaveBeenCalledWith(9n)
})
it('reports a failed descriptor LocalFree after a successful apply', () => {
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, 0n)
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked
})
const sid = craftSid(1, 0)
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('LocalFree')
})
it('reports a failed merged-ACL LocalFree after a successful apply', () => {
// No existing descriptor (the default stub): the merge's only LocalFree
// is the merged ACL's, which "fails" and is checked after the apply.
const api = aclApi({ localFree: vi.fn(() => 1n as NativePtr) })
const sid = craftSid(1, 0)
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('LocalFree')
})
})
describe('the exact-ACE skip and DACL-walk defenses', () => {
it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => {
const sid = craftSid(1, 0)
const localFree = vi.fn(() => 0n as NativePtr)
const setNamedSecurityInfoW = vi.fn(() => 0)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
localFree,
setNamedSecurityInfoW,
})
grantWrite(api, 'C:\\granted', sid)
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
expect(localFree).toHaveBeenCalledWith(6n)
})
it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => {
const sid = craftSid(1, 0)
const localFree = vi.fn(() => 0n as NativePtr)
const setNamedSecurityInfoW = vi.fn(() => 0)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor
return 0
}),
localFree,
setNamedSecurityInfoW,
})
grantWrite(api, 'C:\\granted', sid)
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
expect(localFree).not.toHaveBeenCalled()
})
it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => {
const sid = craftSid(1, 0)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true)))
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
localFree: vi.fn(() => 1n as NativePtr),
})
let caught: unknown
try {
grantWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('LocalFree')
})
it('falls back to the merge path when the standing ACE names a different SID', () => {
const sid = craftSid(1, 0)
const setNamedSecurityInfoW = vi.fn(() => 0)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false)))
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
setNamedSecurityInfoW,
})
grantWrite(api, 'C:\\granted', sid)
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
})
it('treats an implausibly small ACL size as no exact grant', () => {
const sid = craftSid(1, 0)
const acl = allocBytes(32)
koffi.encode(acl, 'uint8', 2)
koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header
koffi.encode(acl, 4, 'uint16', 1)
const setNamedSecurityInfoW = vi.fn(() => 0)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, ptrAddress(acl))
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
setNamedSecurityInfoW,
})
grantWrite(api, 'C:\\granted', sid)
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
})
it('treats an ACE that would overrun the ACL as no exact grant', () => {
const sid = craftSid(1, 0)
const acl = allocBytes(32)
koffi.encode(acl, 'uint8', 2)
koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE
koffi.encode(acl, 4, 'uint16', 1)
koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size
const setNamedSecurityInfoW = vi.fn(() => 0)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, ptrAddress(acl))
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
setNamedSecurityInfoW,
})
grantWrite(api, 'C:\\granted', sid)
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1)
})
})
describe('revokeWrite no-DACL path', () => {
it('reports nothing to revoke when the read yields neither DACL nor descriptor', () => {
// The default stub encodes a NULL DACL and a NULL descriptor.
const api = aclApi()
const sid = craftSid(1, 0)
expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false)
})
it('frees a descriptor that carries no DACL and reports nothing to revoke', () => {
const localFree = vi.fn(() => 0n as NativePtr)
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, 0n)
koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL
return 0
}),
localFree,
})
const sid = craftSid(1, 0)
expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false)
expect(localFree).toHaveBeenCalledWith(6n)
})
it('reports a failed descriptor LocalFree on the no-DACL path', () => {
const api = aclApi({
getNamedSecurityInfoW: vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, 0n)
koffi.encode(descriptor, PVOID, 6n)
return 0
}),
localFree: vi.fn(() => 1n as NativePtr),
})
const sid = craftSid(1, 0)
let caught: unknown
try {
revokeWrite(api, 'C:\\granted', sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('LocalFree')
})
})

View File

@@ -11,7 +11,8 @@ 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'
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts'
import * as abi from '../src/win32-abi.ts'
const PVOID = koffi.pointer('void')
@@ -136,3 +137,318 @@ describe('getTempPath buffer defense', () => {
expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u)
})
})
/** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */
function pipeOkApi(overrides: Partial<Win32Bindings> = {}): {
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((
_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
}),
getLastError: vi.fn(() => 5),
closeHandle,
formatMessageW: vi.fn(() => 0),
...overrides,
} as unknown as Win32Bindings
return { api, closed, closeHandle }
}
describe('spawn pipe failures close their handles', () => {
const token = 1n as NativePtr
it('spawnSandboxed reports a CreatePipe failure', () => {
const api = { createPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
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('CreatePipe')
})
it('spawnSandboxed reports a NULL pipe handle after CreatePipe succeeds', () => {
const api = { createPipe: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
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('CreatePipe')
})
it('spawnSandboxed reports a SetHandleInformation failure', () => {
const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) })
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('SetHandleInformation')
})
it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => {
const { api } = pipeOkApi({
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: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
return 1
}),
})
expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }))
.toThrow(/null process\/thread handles/u)
})
})
describe('spawnSandboxedInherited failure paths', () => {
const token = 1n as NativePtr
/** The stub the inherited-happy path needs; overrides flip one call per test. */
function inheritedApi(overrides: Partial<Win32Bindings> = {}): {
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(() => 0),
getLastError: vi.fn(() => 5),
closeHandle,
formatMessageW: vi.fn(() => 0),
...overrides,
} as unknown as Win32Bindings
return { api, closed, closeHandle }
}
it('closes the job and reports when GetStdHandle yields a NULL handle', () => {
const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) })
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('GetStdHandle')
expect(closeHandle).toHaveBeenCalledWith(100n)
})
it('reports a SetHandleInformation failure while enabling stdio inheritance', () => {
const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) })
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('SetHandleInformation')
})
it('closes the job and reports when CreateProcessAsUserW fails', () => {
const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) })
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('CreateProcessAsUserW')
expect(closeHandle).toHaveBeenCalledWith(100n)
})
it('closes the job and rejects NULL process/thread handles after a successful spawn', () => {
const { api, closeHandle } = inheritedApi({
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: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
return 1
}),
})
expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }))
.toThrow(/null process\/thread handles/u)
expect(closeHandle).toHaveBeenCalledWith(100n)
})
it('closes the job and reports when SetInformationJobObject fails', () => {
const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) })
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('SetInformationJobObject')
expect(closeHandle).toHaveBeenCalledWith(100n)
})
it('closes the job and reports a NULL job object', () => {
const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) })
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('CreateJobObjectW')
})
it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => {
const { api, closeHandle } = inheritedApi()
const spawned = spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
expect(spawned.pid).toBe(1234)
expect(spawned.process).toBe(200n)
expect(spawned.job).toBe(100n)
// thread handle closed by the spawn; process and job handles stay with the caller.
expect(closeHandle).toHaveBeenCalledWith(201n)
expect(closeHandle).not.toHaveBeenCalledWith(200n)
expect(closeHandle).not.toHaveBeenCalledWith(100n)
})
})
describe('drainPipe', () => {
it('stops at ERROR_NO_DATA and closes the read end', () => {
const closeHandle = vi.fn(() => 1)
const api = {
peekNamedPipe: vi.fn(() => 0),
getLastError: vi.fn(() => abi.ERROR_NO_DATA),
closeHandle,
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return drainPipe(api, 30n as NativePtr).then((buffer) => {
expect(buffer.length).toBe(0)
expect(closeHandle).toHaveBeenCalledWith(30n)
})
})
it('reports a PeekNamedPipe failure that is not a clean EOF', () => {
const api = {
peekNamedPipe: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
closeHandle: vi.fn(() => 1),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' })
})
it('reports a ReadFile failure after data was reported available', () => {
const api = {
peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
koffi.encode(totalAvail, 'uint32', 4)
return 1
}),
readFile: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
closeHandle: vi.fn(() => 1),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' })
})
it('drains one chunk and stops at ERROR_BROKEN_PIPE', () => {
let peeks = 0
const api = {
peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
peeks++
if (peeks > 1) return 0
koffi.encode(totalAvail, 'uint32', 4)
return 1
}),
readFile: vi.fn((_file: unknown, chunk: Buffer, _count: unknown, read: NativePtr) => {
chunk.write('ab', 0, 'utf8')
koffi.encode(read, 'uint32', 2)
return 1
}),
getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE),
closeHandle: vi.fn(() => 1),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return drainPipe(api, 30n as NativePtr).then((buffer) => {
expect(buffer.toString('utf8')).toBe('ab')
})
})
})
describe('waitForExit', () => {
it('reports a WaitForSingleObject failure', () => {
const api = {
waitForSingleObject: vi.fn(() => 0xFFFFFFFF),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error)
})
it('reports a GetExitCodeProcess failure', () => {
const api = {
waitForSingleObject: vi.fn(() => 0),
getExitCodeProcess: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error)
})
it('returns the exit code and closes the process handle', () => {
const closeHandle = vi.fn(() => 1)
const api = {
waitForSingleObject: vi.fn(() => 0),
getExitCodeProcess: vi.fn((_process: unknown, slot: NativePtr) => {
koffi.encode(slot, 'uint32', 42)
return 1
}),
closeHandle,
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
expect(waitForExit(api, 200n as NativePtr)).toBe(42)
expect(closeHandle).toHaveBeenCalledWith(200n)
})
})

View File

@@ -0,0 +1,202 @@
/**
* FFI helper tests with stub binding tables (the failure-paths.spec.ts
* pattern): error formatting and temp-path decoding defenses, the
* last-error throwers' detail fallback, pointer decode NULL handling, and
* the bounded SID comparison's early exits. Pure stubs — no real Win32
* calls, so these run on every platform; the real-FFI round-trip lives in
* acl.spec.ts and probe.spec.ts (win32 only).
*/
import { describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { Win32Error } from '../src/errors.ts'
import {
allocBytes, decodePtr, decodePtrAt, errorText, getTempPath,
isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32,
} from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import * as abi from '../src/win32-abi.ts'
const PVOID = koffi.pointer('void')
/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */
function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType<typeof vi.fn> } {
const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => {
const text = 'access denied'
buffer.write(text, 'utf16le')
return text.length
})
const api = {
formatMessageW,
getLastError: vi.fn(() => 5),
} as unknown as Win32Bindings
return { api, formatMessageW }
}
/** A minimal SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2, subauthorities@8. */
function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 0], subs: number[] = []): NativePtr {
const sid = allocBytes(8 + subs.length * 4)
koffi.encode(sid, 'uint8', revision)
koffi.encode(sid, 1, 'uint8', count)
authority.forEach((byte, index) => {
koffi.encode(sid, 2 + index, 'uint8', byte)
})
subs.forEach((sub, index) => {
koffi.encode(sid, 8 + index * 4, 'uint32', sub)
})
return sid
}
describe('errorText', () => {
it('decodes the formatted UTF-16 message and trims it', () => {
const { api } = formatApi()
expect(errorText(api, 5)).toBe('access denied')
})
it('returns an empty string when FormatMessageW formats nothing', () => {
const api = { formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings
expect(errorText(api, 5)).toBe('')
})
})
describe('getTempPath', () => {
it('decodes the NUL-terminated temp path GetTempPathW wrote', () => {
const api = {
getTempPathW: vi.fn((_length: number, buffer: Buffer) => {
buffer.write('C:\\TEMP', 'utf16le')
return 7
}),
} as unknown as Win32Bindings
expect(getTempPath(api)).toBe('C:\\TEMP')
})
it('reports the Win32 failure when GetTempPathW writes nothing', () => {
const { api } = formatApi()
const failing = { ...api, getTempPathW: vi.fn(() => 0) } as Win32Bindings
let caught: unknown
try {
getTempPath(failing)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('GetTempPathW')
})
})
describe('throwLastError and throwWin32', () => {
it('throwLastError formats the system message when no detail is given', () => {
const { api } = formatApi()
let caught: unknown
try {
throwLastError(api, 'Probe')
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied')
})
it('throwWin32 formats the system message when no detail is given', () => {
const { api } = formatApi()
let caught: unknown
try {
throwWin32(api, 'Probe', 5)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied')
})
it('Win32Error appends the detail when one is given', () => {
const error = new Win32Error('Probe', 5, 'the lock file path')
expect(error.name).toBe('Win32Error')
expect(error.api).toBe('Probe')
expect(error.win32Code).toBe(5)
expect(error.message).toBe('Probe failed (Win32 5): the lock file path')
})
it('Win32Error omits the detail suffix when none is given', () => {
const error = new Win32Error('Probe', 5)
expect(error.message).toBe('Probe failed (Win32 5)')
})
})
describe('pointer NULL handling', () => {
it('isNullPtr accepts null, undefined, and the zero pointer', () => {
expect(isNullPtr(null)).toBe(true)
expect(isNullPtr(undefined)).toBe(true)
expect(isNullPtr(0n as NativePtr)).toBe(true)
expect(isNullPtr(42n as NativePtr)).toBe(false)
})
it('isInvalidHandle treats NULL as failure', () => {
expect(isInvalidHandle(null)).toBe(true)
expect(isInvalidHandle(undefined)).toBe(true)
expect(isInvalidHandle(0n as NativePtr)).toBe(true)
expect(isInvalidHandle(42n as NativePtr)).toBe(false)
})
it('decodePtrAt returns null for a NULL pointer stored in a buffer', () => {
const buffer = Buffer.alloc(8)
buffer.writeBigUInt64LE(0n, 0)
expect(decodePtrAt(buffer, 0)).toBeNull()
})
it('decodePtrAt returns the stored pointer value', () => {
const buffer = Buffer.alloc(8)
buffer.writeBigUInt64LE(42n, 0)
expect(decodePtrAt(buffer, 0)).toBe(42n)
})
it('decodePtr returns null for an unset out-parameter slot', () => {
const slot = koffi.alloc(PVOID, 1) as unknown as NativePtr
expect(decodePtr(slot)).toBeNull()
})
})
describe('sameSidAt bounded comparison', () => {
it('rejects a revision mismatch before comparing anything else', () => {
const left = craftSid(1, 0)
const right = craftSid(2, 0)
expect(sameSidAt(left, 0, right, 0)).toBe(false)
})
it('rejects a subauthority-count mismatch', () => {
const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42])
const right = craftSid(1, 2, [0, 0, 0, 0, 0, 5], [42, 43])
expect(sameSidAt(left, 0, right, 0)).toBe(false)
})
it('rejects an implausible subauthority count', () => {
const left = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1)
const right = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1)
expect(sameSidAt(left, 0, right, 0)).toBe(false)
})
it('rejects a differing identifier authority byte', () => {
const left = craftSid(1, 0, [0, 0, 0, 0, 0, 5])
const right = craftSid(1, 0, [0, 0, 0, 0, 0, 6])
expect(sameSidAt(left, 0, right, 0)).toBe(false)
})
it('accepts identical SIDs at nonzero offsets over differing leading bytes', () => {
const sid = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42])
// Embed the same SID bytes at offset 4 of two buffers whose first four
// bytes differ: an offset-ignoring comparison reads the differing
// prefixes and must reject.
const left = allocBytes(4 + 12)
const right = allocBytes(4 + 12)
koffi.encode(left, 0, 'uint32', 0x11111111)
koffi.encode(right, 0, 'uint32', 0x22222222)
for (let offset = 0; offset < 12; offset++) {
const byte = koffi.decode(sid, offset, 'uint8') as number
koffi.encode(left, 4 + offset, 'uint8', byte)
koffi.encode(right, 4 + offset, 'uint8', byte)
}
expect(sameSidAt(left, 4, right, 4)).toBe(true)
expect(sameSidAt(left, 0, right, 0)).toBe(false) // the differing prefixes are not a matching SID
})
})

View File

@@ -0,0 +1,417 @@
/**
* AclSandbox orchestration failure-path tests: the win32 resolver is mocked
* to hand each test a stub binding table, so every checked Win32 call in
* init/spawn/dispose has a failing counterpart without opening real token or
* ACL handles. Constructor validation, the fail-closed init cleanup, and the
* dispose aggregation use the same stubs. Pure stubs — no real Win32 calls,
* so these run on every platform; the real-FFI round-trip lives in
* acl.spec.ts and runner.spec.ts (win32 only).
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { PROCESS_INFORMATION } from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import { Win32Error } from '../src/errors.ts'
import { AclSandbox } from '../src/index.ts'
import * as abi from '../src/win32-abi.ts'
const PVOID = koffi.pointer('void')
type MockFn = ReturnType<typeof vi.fn>
/** The stub binding table plus the mocks the assertions inspect directly. */
interface HappyStubs {
api: Win32Bindings
setNamedSecurityInfoW: MockFn
convertStringSidToSidW: MockFn
closeHandle: MockFn
localFree: MockFn
createRestrictedToken: MockFn
createJobObjectW: MockFn
getNamedSecurityInfoW: MockFn
}
const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined }))
vi.mock('../src/ffi.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/ffi.ts')>()
return {
...actual,
win32: () => Promise.resolve(state.stubs?.api as Win32Bindings),
win32Sync: () => state.stubs?.api as Win32Bindings,
}
})
const scratchDirs: string[] = []
afterAll(() => {
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function scratch(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-'))
scratchDirs.push(dir)
return dir
}
/**
* The stub the whole happy pipeline needs: token opening, capability-SID
* parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token,
* default-DACL merge, piped/inherited spawns, drains, and exit waits all
* succeed. Every test flips one call per branch.
*/
function happyStubs(): HappyStubs {
let next = 0n
const fresh = () => ++next
const openProcess = vi.fn(() => fresh())
const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => {
koffi.encode(slot, PVOID, fresh())
return 1
})
const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => {
koffi.encode(slot, PVOID, fresh())
return 1
})
const getTempPathW = vi.fn((_length: number, buffer: Buffer) => {
const temp = tmpdir().replace(/[\\/]$/u, '')
buffer.write(temp, 'utf16le')
return temp.length
})
const createFileW = vi.fn(() => fresh())
const getNamedSecurityInfoW = vi.fn((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
koffi.encode(dacl, PVOID, 0n)
koffi.encode(descriptor, PVOID, 0n)
return 0
})
const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
koffi.encode(newAcl, PVOID, fresh())
return 0
})
const setNamedSecurityInfoW = vi.fn(() => 0)
const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
if (info === null) {
koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8)
return 0 // the size probe is expected to "fail"
}
if (cls === abi.TokenGroups) {
info.writeUInt32LE(1, 0)
info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET)
info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8)
} else {
info.writeBigUInt64LE(88n, 0) // the token's current default DACL
}
return 1
})
const getLengthSid = vi.fn(() => 12)
const copySid = vi.fn(() => 1)
const createWellKnownSid = vi.fn(() => 1)
const isValidSid = vi.fn(() => 1)
const createRestrictedToken = vi.fn((
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
_rc: unknown, _rs: unknown, slot: NativePtr,
) => {
koffi.encode(slot, PVOID, fresh())
return 1
})
const setTokenInformation = vi.fn(() => 1)
const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
koffi.encode(readSlot, PVOID, fresh())
koffi.encode(writeSlot, PVOID, fresh())
return 1
})
const setHandleInformation = vi.fn(() => 1)
const 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: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 })
return 1
})
const peekNamedPipe = vi.fn(() => 0)
const readFile = vi.fn(() => 1)
const waitForSingleObject = vi.fn(() => 0)
const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => {
koffi.encode(slot, 'uint32', 42)
return 1
})
const createJobObjectW = vi.fn(() => fresh())
const setInformationJobObject = vi.fn(() => 1)
const assignProcessToJobObject = vi.fn(() => 1)
const resumeThread = vi.fn(() => 0)
const getStdHandle = vi.fn(() => fresh())
const localFree = vi.fn(() => 0n)
const closeHandle = vi.fn(() => 1)
const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF
const formatMessageW = vi.fn(() => 0)
const api = {
openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW,
lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1),
getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation,
getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken,
setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW,
peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW,
setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle,
localFree, closeHandle, getLastError, formatMessageW,
} as unknown as Win32Bindings
return {
api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree,
createRestrictedToken, createJobObjectW, getNamedSecurityInfoW,
}
}
beforeEach(() => {
state.stubs = happyStubs()
})
describe('AclSandbox constructor validation', () => {
it('rejects a writable directory that does not exist', () => {
const missing = join(scratch(), 'missing')
expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' }))
.toThrow(/writable dir does not exist/u)
})
it('resolves relative writable directories to absolute paths', () => {
const dir = scratch()
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' })
expect(sandbox.writableDirs).toEqual([resolve(dir)])
expect(sandbox.mode).toBe('read-only')
expect(sandbox.tempDir).toBeUndefined()
})
})
describe('AclSandbox init', () => {
it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => {
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
const temp = scratch()
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: temp,
writeSid: 'S-1-4-9000-1',
tempWriteSid: 'S-1-4-9000-1-1',
mode: 'workspace-write',
})
await sandbox.init()
expect(sandbox.tempDir).toBe(resolve(temp))
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2)
})
it('requires an explicit private temp directory or null under workspace-write', () => {
const workspace = scratch()
expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }))
.toThrow(/requires an explicit private temp directory or null/u)
})
it('applies no grants when the temp dir option is null', async () => {
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
await sandbox.init()
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only
})
it('rejects a temp dir that does not exist', async () => {
const workspace = scratch()
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: join(scratch(), 'missing'),
writeSid: 'S-1-4-9000-4',
tempWriteSid: 'S-1-4-9000-4-1',
mode: 'workspace-write',
})
await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u)
})
it('builds a read-only token without parsing a write SID or applying grants', async () => {
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' })
await sandbox.init()
expect(convertStringSidToSidW).not.toHaveBeenCalled()
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free
})
it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => {
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false })
await sandbox.init()
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke
})
it('refuses a second init on the same instance', async () => {
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' })
await sandbox.init()
await expect(sandbox.init()).rejects.toThrow(/already initialized/u)
})
it('reports a ConvertStringSidToSidW failure before granting anything', async () => {
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
convertStringSidToSidW.mockReturnValue(0)
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' })
await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
})
it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => {
const { convertStringSidToSidW } = state.stubs as HappyStubs
convertStringSidToSidW.mockImplementation(() => 1) // no out slot write
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' })
await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error)
})
it('reports a failed close of the current process token', async () => {
const { closeHandle } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' })
// fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the
// token-layer close of 1n succeeds and init's close of 2n fails.
closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1))
// The failure lands after this.token is stored but before this.api is
// assigned. Cleanup retries the still-open handle and reports both close
// failures after releasing the restricted token and parsed SIDs.
await expect(sandbox.init()).rejects.toMatchObject({
errors: [
{ api: 'CloseHandle' },
{ api: 'CloseHandle' },
],
})
})
it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => {
const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
const temp = scratch()
let inCleanup = false
createRestrictedToken.mockImplementation(() => {
inCleanup = true // the grants already landed: every later call is the cleanup's
return 0
})
localFree.mockImplementation(() => (inCleanup ? 1n : 0n))
getNamedSecurityInfoW.mockImplementation((
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
) => {
if (inCleanup) return 2 // the cleanup's revocation read fails too
koffi.encode(dacl, PVOID, 0n)
koffi.encode(descriptor, PVOID, 0n)
return 0
})
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: temp,
writeSid: 'S-1-4-9000-10',
tempWriteSid: 'S-1-4-9000-10-1',
mode: 'workspace-write',
})
await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u)
expect(sandbox.tempDir).toBeUndefined()
})
})
describe('AclSandbox spawn', () => {
it('refuses to spawn before init', () => {
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' })
expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u)
})
it('pipe spawn drains empty pipes and settles with the child exit code', async () => {
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' })
await sandbox.init()
const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace })
expect(child.pid).toBe(1234)
const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }
await expect(child.wait()).resolves.toEqual(expected)
// The second wait reuses the settled exit-code promise instead of re-waiting.
await expect(child.wait()).resolves.toEqual(expected)
})
it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => {
const { closeHandle } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' })
await sandbox.init()
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 })
expect(closeHandle).toHaveBeenCalled()
})
it('inherit spawn reports a failed close of the kill-on-close job', async () => {
const { closeHandle, createJobObjectW } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' })
await sandbox.init()
let jobHandle = 0n
closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1))
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr
await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' })
})
})
describe('AclSandbox dispose', () => {
it('is a no-op before init', () => {
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' })
expect(() => { sandbox.dispose() }).not.toThrow()
})
it('aggregates a failing temp revocation into an AggregateError', async () => {
const { getNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
const temp = scratch()
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: temp,
writeSid: 'S-1-4-9000-16',
tempWriteSid: 'S-1-4-9000-16-1',
mode: 'workspace-write',
})
await sandbox.init()
getNamedSecurityInfoW.mockReturnValue(2)
expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u)
})
it('aggregates SID and token cleanup failures into an AggregateError', async () => {
const { localFree } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' })
await sandbox.init()
localFree.mockReturnValue(1n)
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
})
it('reports a failed close of the restricted token', async () => {
const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' })
let restrictedToken = 0n
createRestrictedToken.mockImplementation((
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
_rc: unknown, _rs: unknown, slot: NativePtr,
) => {
restrictedToken = 99n
koffi.encode(slot, PVOID, restrictedToken)
return 1
})
closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1))
await sandbox.init()
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
})
})

View File

@@ -0,0 +1,436 @@
/**
* Restricted-token failure-path tests with stub binding tables (the
* failure-paths.spec.ts pattern): every checked Win32 call in the token
* pipeline — open, logon-SID scan, well-known SID creation, default-DACL
* merge, restricted-token creation — has a failing counterpart, and each
* failure closes or frees what it created before throwing. Pure stubs — no
* real Win32 calls, so these run on every platform; the real-FFI round-trip
* lives in acl.spec.ts (win32 only).
*/
import { describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { allocBytes, isNullPtr } from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import { Win32Error } from '../src/errors.ts'
import {
createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant,
} from '../src/token.ts'
import * as abi from '../src/win32-abi.ts'
const PVOID = koffi.pointer('void')
describe('openCurrentProcessToken failure paths', () => {
it('reports when OpenProcess yields no handle', () => {
const api = {
openProcess: vi.fn(() => 0n),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
let caught: unknown
try {
openCurrentProcessToken(api)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('OpenProcess')
})
it('closes the process handle and reports when OpenProcessToken fails', () => {
const closeHandle = vi.fn(() => 1)
const api = {
openProcess: vi.fn(() => 7n),
openProcessToken: vi.fn(() => 0),
closeHandle,
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
let caught: unknown
try {
openCurrentProcessToken(api)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('OpenProcessToken')
expect(closeHandle).toHaveBeenCalledWith(7n)
})
it('reports a failed CloseHandle of the process handle', () => {
const api = {
openProcess: vi.fn(() => 7n),
openProcessToken: vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => {
koffi.encode(slot, PVOID, 9n)
return 1
}),
closeHandle: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
let caught: unknown
try {
openCurrentProcessToken(api)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CloseHandle')
})
it('rejects a NULL token handle after a successful OpenProcessToken', () => {
const api = {
openProcess: vi.fn(() => 7n),
openProcessToken: vi.fn(() => 1), // succeeds without writing the out slot
closeHandle: vi.fn(() => 1),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
let caught: unknown
try {
openCurrentProcessToken(api)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('OpenProcessToken')
})
})
/**
* The stub the logon-SID scan needs: the size probe writes `needed`, the
* second call fills a TOKEN_GROUPS buffer (GroupCount@0, SID pointer@8,
* attributes@16) with the state's one group. The CopySid mock comes back
* beside the table for the one test that asserts on its arguments.
*/
function logonApi(state: {
needed: number
groupCount: number
sidPtr: bigint
logon: boolean
secondOk?: boolean
sidLength?: number
copyOk?: boolean
}): { api: Win32Bindings; copySid: ReturnType<typeof vi.fn> } {
const copySid = vi.fn(() => (state.copyOk === false ? 0 : 1))
const api = {
getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
if (cls !== abi.TokenGroups) throw new Error(`unexpected token information class ${cls}`)
if (info === null) {
koffi.encode(needed, 'uint32', state.needed)
return 0 // the size probe is expected to "fail"
}
if (state.secondOk === false) return 0
info.writeUInt32LE(state.groupCount, 0)
if (state.groupCount > 0) {
info.writeBigUInt64LE(state.sidPtr, abi.TOKEN_GROUPS_OFFSET)
info.writeUInt32LE(state.logon ? abi.SE_GROUP_LOGON_ID : 0, abi.TOKEN_GROUPS_OFFSET + 8)
}
return 1
}),
getLengthSid: vi.fn(() => state.sidLength ?? 12),
copySid,
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return { api, copySid }
}
describe('findLogonSid failure paths', () => {
const token = 9n as NativePtr
it('reports a size probe that wrote nothing', () => {
const { api } = logonApi({ needed: 0, groupCount: 0, sidPtr: 0n, logon: false })
let caught: unknown
try {
findLogonSid(api, token)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('GetTokenInformation')
})
it('rejects an implausibly small TokenGroups size', () => {
const { api } = logonApi({ needed: 4, groupCount: 0, sidPtr: 0n, logon: false })
let caught: unknown
try {
findLogonSid(api, token)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('GetTokenInformation')
})
it('reports a failed TokenGroups read', () => {
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, secondOk: false })
let caught: unknown
try {
findLogonSid(api, token)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('GetTokenInformation')
})
it('skips a NULL group SID pointer and throws when no logon SID remains', () => {
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 0n, logon: true })
expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u)
})
it('skips a non-logon group and throws when no logon SID remains', () => {
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: false })
expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u)
})
it('reports a zero logon-SID length', () => {
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, sidLength: 0 })
let caught: unknown
try {
findLogonSid(api, token)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('GetLengthSid')
})
it('reports a failed CopySid of the logon SID', () => {
const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, copyOk: false })
let caught: unknown
try {
findLogonSid(api, token)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CopySid')
})
it('copies the logon SID and returns the new allocation', () => {
const { api, copySid } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true })
const copy = findLogonSid(api, token)
expect(isNullPtr(copy)).toBe(false)
expect(copySid).toHaveBeenCalledWith(12, copy, 77n)
})
})
describe('makeWellKnownSid failure paths', () => {
it('reports when CreateWellKnownSid fails', () => {
const api = {
createWellKnownSid: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
let caught: unknown
try {
makeWellKnownSid(api, abi.WinWorldSid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CreateWellKnownSid')
})
it('reports when the created well-known SID is invalid', () => {
const api = {
createWellKnownSid: vi.fn(() => 1),
isValidSid: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
let caught: unknown
try {
makeWellKnownSid(api, abi.WinWorldSid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('IsValidSid')
})
})
/**
* The stub the default-DACL merge needs: the size probe writes `needed`, the
* second call fills the DACL pointer slot, and the merge/apply calls follow
* the state's results.
*/
function daclApi(state: {
needed: number
currentDacl: bigint
secondOk?: boolean
mergeResult?: number
newDacl: bigint
setTokenInfo?: number
}): Win32Bindings {
const api = {
getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => {
if (cls !== abi.TokenDefaultDacl) throw new Error(`unexpected token information class ${cls}`)
if (info === null) {
koffi.encode(needed, 'uint32', state.needed)
return 0 // the size probe is expected to "fail"
}
if (state.secondOk === false) return 0
info.writeBigUInt64LE(state.currentDacl, 0)
return 1
}),
setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
if (state.mergeResult !== undefined && state.mergeResult !== 0) return state.mergeResult
koffi.encode(newAcl, PVOID, state.newDacl)
return 0
}),
setTokenInformation: vi.fn(() => state.setTokenInfo ?? 1),
localFree: vi.fn(() => 0n),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
return api
}
describe('setTokenDefaultDaclGrant failure paths', () => {
const token = 9n as NativePtr
const sid = 77n as NativePtr
it('reports a size probe that wrote nothing', () => {
const api = daclApi({ needed: 0, currentDacl: 0n, newDacl: 0n })
let caught: unknown
try {
setTokenDefaultDaclGrant(api, token, sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('GetTokenInformation')
})
it('reports a failed default-DACL read', () => {
const api = daclApi({ needed: 8, currentDacl: 88n, secondOk: false, newDacl: 0n })
let caught: unknown
try {
setTokenDefaultDaclGrant(api, token, sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('GetTokenInformation')
})
it('rejects a token that carries no default DACL', () => {
const api = daclApi({ needed: 8, currentDacl: 0n, newDacl: 0n })
expect(() => { setTokenDefaultDaclGrant(api, token, sid) }).toThrow(/no default DACL/u)
})
it('reports a failed SetEntriesInAclW merge', () => {
const api = daclApi({ needed: 8, currentDacl: 88n, mergeResult: 5, newDacl: 0n })
let caught: unknown
try {
setTokenDefaultDaclGrant(api, token, sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
})
it('rejects a NULL merged default DACL', () => {
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 0n })
let caught: unknown
try {
setTokenDefaultDaclGrant(api, token, sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetEntriesInAclW')
})
it('frees the merged DACL and reports when SetTokenInformation fails', () => {
const localFree = vi.fn(() => 0n)
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n, setTokenInfo: 0 })
;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree)
let caught: unknown
try {
setTokenDefaultDaclGrant(api, token, sid)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('SetTokenInformation')
expect(localFree).toHaveBeenCalledWith(99n)
})
it('frees the merged DACL after a successful apply', () => {
const localFree = vi.fn(() => 0n)
const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n })
;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree)
setTokenDefaultDaclGrant(api, token, sid)
expect(localFree).toHaveBeenCalledWith(99n)
})
})
describe('createRestrictedToken failure paths', () => {
it('builds the read-only restricting list without a write SID', () => {
const create = vi.fn((
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
count: number, _sids: unknown, slot: NativePtr,
) => {
koffi.encode(slot, PVOID, 9n)
expect(count).toBe(2)
return 1
})
const api = { createRestrictedToken: create } as unknown as Win32Bindings
const logon = allocBytes(12)
expect(createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')).toBe(9n)
})
it('builds the workspace-write restricting list with the write SID', () => {
const create = vi.fn((
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
count: number, _sids: unknown, slot: NativePtr,
) => {
koffi.encode(slot, PVOID, 9n)
expect(count).toBe(3)
return 1
})
const api = { createRestrictedToken: create } as unknown as Win32Bindings
const logon = allocBytes(12)
expect(createRestrictedToken(api, 1n as NativePtr, logon, [3n as NativePtr], { world: 2n as NativePtr }, 'workspace-write')).toBe(9n)
})
it('reports when CreateRestrictedToken fails', () => {
const api = {
createRestrictedToken: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
const logon = allocBytes(12)
let caught: unknown
try {
createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CreateRestrictedToken')
})
it('rejects a NULL token handle after a successful CreateRestrictedToken', () => {
const api = {
createRestrictedToken: vi.fn(() => 1), // succeeds without writing the out slot
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32Bindings
const logon = allocBytes(12)
let caught: unknown
try {
createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Win32Error)
expect((caught as Win32Error).api).toBe('CreateRestrictedToken')
})
})

View File

@@ -280,7 +280,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'clientModuleHost',
summary: 'The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap.',
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap.',
methods: [
{
signature: 'graph(): WebBootGraph',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/typert/generator/README.md
README.md: 38030c2b7e07c70ab79001086640b6581943dbd9
README.zh.md: 0cf0a365785775a9461e9744d29a0da482e54d14
README.md: ac2610620af14a30143e95921273a8c254b50a17
README.zh.md: a1fa777493578533c93db6cd3354b89c8757d837

Some files were not shown because too many files have changed in this diff Show More