mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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` = [直接使用核心 Agent/Session 的入口](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 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both 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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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 都到达 ACTIVE,FAILED/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 都到达 ACTIVE,FAILED/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 分发 switch;host 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支。
|
||||
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 监视与 SSE(Server-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` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 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` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 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 联邦不支持的形态 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both 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
|
||||
|
||||
@@ -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 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 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` 行(浏览器 roster;modules 行同时是 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()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 与 Effort;Model 可深入提供方分组,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 占用方。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 编译并打包新的贡献:
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 中分类,并设有完整性守卫。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 依赖之。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
202
packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts
Normal file
202
packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent 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
Reference in New Issue
Block a user