From 668da7f507afb7404bfc6e4721e34f541d8a4f44 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:08:46 +0800 Subject: [PATCH 01/23] refactor(win32-process): share native process primitives --- ...-shared-win32-process-primitives.i18n.yaml | 6 + ...6-08-19-shared-win32-process-primitives.md | 35 ++ ...8-19-shared-win32-process-primitives.zh.md | 35 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 2 +- .../sandbox/sandbox-windows-acl/README.zh.md | 2 +- .../sandbox/sandbox-windows-acl/package.json | 1 + .../sandbox/sandbox-windows-acl/src/errors.ts | 21 - .../sandbox/sandbox-windows-acl/src/ffi.ts | 595 ++++++------------ .../sandbox/sandbox-windows-acl/src/index.ts | 54 +- .../sandbox/sandbox-windows-acl/src/spawn.ts | 375 ++--------- .../sandbox-windows-acl/src/win32-abi.ts | 296 ++------- .../tests/acl-failure-paths.spec.ts | 6 +- .../sandbox-windows-acl/tests/ffi.spec.ts | 24 +- .../tests/grant-failure-paths.spec.ts | 4 +- .../tests/index-failure-paths.spec.ts | 66 +- .../sandbox-windows-acl/tests/quote.spec.ts | 88 --- .../tests/token-failure-paths.spec.ts | 6 +- .../sandbox/sandbox-windows-acl/tsconfig.json | 3 + .../sandbox-windows-acl/verify/abi-probe.cpp | 245 ++------ packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 1 + packages/subprocess/README.zh.md | 1 + .../subprocess/win32-process/README.i18n.yaml | 6 + packages/subprocess/win32-process/README.md | 39 ++ .../subprocess/win32-process/README.zh.md | 39 ++ .../subprocess/win32-process/package.json | 45 ++ packages/subprocess/win32-process/src/abi.ts | 38 ++ .../subprocess/win32-process/src/errors.ts | 14 + packages/subprocess/win32-process/src/ffi.ts | 319 ++++++++++ .../subprocess/win32-process/src/index.ts | 29 + .../subprocess/win32-process/src/invariant.ts | 17 + .../subprocess/win32-process/src/process.ts | 431 +++++++++++++ .../win32-process/tests/ffi.spec.ts | 45 ++ .../win32-process/tests/invariant.spec.ts | 16 + .../tests/process-allocation-failure.spec.ts | 145 +++++ .../tests/process-failure-paths.spec.ts} | 179 +++--- .../win32-process/tests/process.spec.ts | 243 +++++++ .../win32-process/tests/quote.spec.ts | 65 ++ .../subprocess/win32-process/tsconfig.json | 13 + .../win32-process/verify/abi-probe.cpp | 48 ++ pnpm-lock.yaml | 16 + tsconfig.host.json | 1 + 49 files changed, 2239 insertions(+), 1399 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md create mode 100644 .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md delete mode 100644 packages/sandbox/sandbox-windows-acl/src/errors.ts delete mode 100644 packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts create mode 100644 packages/subprocess/win32-process/README.i18n.yaml create mode 100644 packages/subprocess/win32-process/README.md create mode 100644 packages/subprocess/win32-process/README.zh.md create mode 100644 packages/subprocess/win32-process/package.json create mode 100644 packages/subprocess/win32-process/src/abi.ts create mode 100644 packages/subprocess/win32-process/src/errors.ts create mode 100644 packages/subprocess/win32-process/src/ffi.ts create mode 100644 packages/subprocess/win32-process/src/index.ts create mode 100644 packages/subprocess/win32-process/src/invariant.ts create mode 100644 packages/subprocess/win32-process/src/process.ts create mode 100644 packages/subprocess/win32-process/tests/ffi.spec.ts create mode 100644 packages/subprocess/win32-process/tests/invariant.spec.ts create mode 100644 packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts rename packages/{sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts => subprocess/win32-process/tests/process-failure-paths.spec.ts} (71%) create mode 100644 packages/subprocess/win32-process/tests/process.spec.ts create mode 100644 packages/subprocess/win32-process/tests/quote.spec.ts create mode 100644 packages/subprocess/win32-process/tsconfig.json create mode 100644 packages/subprocess/win32-process/verify/abi-probe.cpp diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml new file mode 100644 index 0000000000..053fadffc3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +2026-08-19-shared-win32-process-primitives.md: ab23b02dfb4e937891b26b009900696ada3fa3c0 +2026-08-19-shared-win32-process-primitives.zh.md: e8686d9f4d1ac2d05c0eecf025ada19d491e50d2 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md new file mode 100644 index 0000000000..ab23b02dfb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -0,0 +1,35 @@ +# Agent Note: Windows sandbox process primitives have one low-level owner + +Status: implemented + +English | [中文](2026-08-19-shared-win32-process-primitives.zh.md) + +## Problem + +The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace policy, but its process launch path also carried the generic Koffi ABI, command-line quoting, anonymous pipes, inherited stdio, Job setup, waits, and HANDLE cleanup. A second Windows process consumer would otherwise have to depend on sandbox policy or copy native resource logic, while fixes to allocation and failure cleanup would need to remain synchronized. + +## Decision + +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. + +The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. + +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Successful inherited-stdio creation returns the process plus kill-on-close Job after the child is suspended, assigned to the Job, and resumed; assignment failure terminates the suspended child before releasing its handles. The sandbox owns returned process, pipe, and Job handles until wait or disposal. + +The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. + +## Verification + +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended Job assignment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile the header probe and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. + +## Alternatives considered + +**Keep process primitives inside the sandbox package.** Rejected because a process consumer would inherit ACL/token policy or duplicate the native ABI and cleanup paths. + +**Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. + +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused `CreateProcessW`, application-name, parent-stdio, and Job-settlement APIs would freeze speculative obligations and enlarge the failure matrix. + +## Consequences + +The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Future process consumers extend the low-level package only when their production path exists. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md new file mode 100644 index 0000000000..e8686d9f4d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -0,0 +1,35 @@ +# Agent Note:Windows sandbox process primitives 只有一个低层 owner + +Status: implemented + +[English](2026-08-19-shared-win32-process-primitives.md) | 中文 + +## Problem + +Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace policy,但其进程启动路径还同时承载通用 Koffi ABI、命令行引用、匿名管道、继承 stdio、Job 设置、wait 与 HANDLE 清理。第二个 Windows process consumer 否则只能依赖 sandbox policy 或复制 native resource 逻辑,而 allocation 与失败清理修复也必须在多份实现间保持同步。 + +## Decision + +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 + +Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 + +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建成功时,child 已 suspended、指派给 Job 并 resume,随后把 process 与 kill-on-close Job 返回给 sandbox;指派失败会先终止 suspended child,再释放其 handles。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 + +该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 + +## Verification + +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的 suspended Job 指派、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 + +## Alternatives considered + +**把 process primitives 留在 sandbox package。** 拒绝,因为 process consumer 将被迫继承 ACL/token policy,或复制 native ABI 与清理路径。 + +**为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 + +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的 `CreateProcessW`、application-name、parent-stdio 与 Job-settlement API 会冻结推测性义务,并扩大失败矩阵。 + +## Consequences + +sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。后续 process consumer 只在其生产路径存在时扩展低层 package。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09be282242..e1c29a414a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: c379a7a49e4aa670aac3aa203e216b2be8e1955d -config-catalog.zh.md: e897f5d25a485133d4929061dce0b398edfa8c04 +config-catalog.md: fc8c694de61fa66b472b7b825fa0e984b1e4a044 +config-catalog.zh.md: bcef55e34e414de2233f3d0d0964683ea64b61ec diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c379a7a49e..fc8c694de6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3222,3 +3222,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-win32-process` ([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e897f5d25a..bcef55e34e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3225,3 +3225,4 @@ export interface Config { - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-win32-process`([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 144729d0c9..9d7dc49ce8 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 54aa13217a01ed44b44925365526438d4c867928 -module-graph.zh.md: 33c2f53afeb94c6d844f8406c1043d780436f588 +module-graph.md: 207a4ae20f24e4ff369ac154272b916fe6a2c7da +module-graph.zh.md: fdb05d6f92184ec72bdbe59e4022313d91827aa7 diff --git a/docs/module-graph.md b/docs/module-graph.md index 54aa13217a..207a4ae20f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -299,6 +299,7 @@ flowchart TD subgraph group_subprocess["packages/subprocess"] pkg_subprocess["subprocess"] pkg_subprocess_local["subprocess-local"] + pkg_win32_process["win32-process"] end subgraph group_terminal["packages/terminal"] pkg_terminal["terminal"] @@ -352,6 +353,7 @@ flowchart TD pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_protocol --> pkg_invariants @@ -1438,6 +1440,7 @@ flowchart TD | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-protocol`](../packages/typert/protocol) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 33c2f53afe..fdb05d6f92 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -301,6 +301,7 @@ flowchart TD subgraph group_subprocess["packages/subprocess"] pkg_subprocess["subprocess"] pkg_subprocess_local["subprocess-local"] + pkg_win32_process["win32-process"] end subgraph group_terminal["packages/terminal"] pkg_terminal["terminal"] @@ -354,6 +355,7 @@ flowchart TD pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_protocol --> pkg_invariants @@ -1440,6 +1442,7 @@ flowchart TD | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-protocol`](../packages/typert/protocol) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index a394957f49..ace32ae8cf 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: a78f334342196ec1848a4a360e5c60b375a28057 -README.zh.md: 8962653b69b23b92fe763e4fcc90bf45911865f7 +README.md: c31f6452815c5629b49c302ebec408da1f0f4803 +README.zh.md: c6a87075875d3424b47121e32d8465a752149c89 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index a78f334342..c31f645281 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -38,7 +38,7 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work rmSync(tempDir, { recursive: true, force: true }) ``` -A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 8962653b69..c6a8707587 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -38,7 +38,7 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work rmSync(tempDir, { recursive: true, force: true }) ``` -直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 +直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。每个 policy-specific Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.md) 提供的 process primitive 都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 817d52e48f..c30a34f466 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-win32-process": "workspace:^", "koffi": "^3.1.0" }, "devDependencies": { diff --git a/packages/sandbox/sandbox-windows-acl/src/errors.ts b/packages/sandbox/sandbox-windows-acl/src/errors.ts deleted file mode 100644 index b57d6dd466..0000000000 --- a/packages/sandbox/sandbox-windows-acl/src/errors.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Fail-closed Win32 error type. Every backend API failure raises this with the - * API name and the exact Win32 code; the original POC silently ignored every - * failed call and would run children UNRESTRICTED (fail-open) — that is the - * failure mode this class exists to prevent. - * @module @deepseek-ai/dsh-sandbox-windows-acl/errors - */ - -export class Win32Error extends Error { - /** The failing Win32 API name, e.g. `CreateRestrictedToken`. */ - readonly api: string - /** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */ - readonly win32Code: number - - constructor(api: string, win32Code: number, detail?: string) { - super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) - this.name = 'Win32Error' - this.api = api - this.win32Code = win32Code - } -} diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 698f0dc2ee..18e262a66b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -1,512 +1,297 @@ -/** - * Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so - * non-Windows processes never open Win32 libraries. Every function signature - * below was verified against the MinGW Windows headers on this machine - * (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h / - * processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h); - * struct layouts are asserted at load time against verify/abi-probe.cpp. - * @module @deepseek-ai/dsh-sandbox-windows-acl/ffi - */ +/** ACL/token bindings layered on the shared Win32 process owner. */ import koffi from 'koffi' -import { Win32Error } from './errors.ts' +import { + ERROR_INSUFFICIENT_BUFFER, + Win32Error, + extendWin32ProcessBindings, + isNullPtr, + throwLastError, +} from '@deepseek-ai/dsh-win32-process' +import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' import * as abi from './win32-abi.ts' -/** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ -declare const nativePtr: unique symbol -/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ -export type NativePtr = bigint & { readonly [nativePtr]: true } +export { + allocPtrSlot, + allocUint32, + decodePtr, + decodeUint32, + isNullPtr, + throwLastError, + throwWin32, +} from '@deepseek-ai/dsh-win32-process' +export type { NativePtr } from '@deepseek-ai/dsh-win32-process' -/** - * True for NULL pointers, however koffi returns them (null or 0n). - * @param value - a pointer as koffi may hand it back (pointer, null, or 0n). - * @returns a type guard narrowing to the NULL shapes. - */ -export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { - return value === null || value === undefined || (value as bigint) === 0n +type Ptr = ReturnType +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +/** ACL/token calls composed with the generic Win32 process binding table. */ +export interface Win32Bindings extends Win32ProcessBindings { + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number + localAlloc(flags: number, bytes: number): NativePtr + localFree(memory: NativePtr): NativePtr + convertStringSidToSidW(stringSid: string, sid: NativePtr): number + createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number + isValidSid(sid: NativePtr): number + getLengthSid(sid: NativePtr): number + copySid(length: number, destination: NativePtr, source: NativePtr): number + getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number + createRestrictedToken( + existing: NativePtr, + flags: number, + disableCount: number, + disableSids: null, + deletePrivilegeCount: number, + privilegesToDelete: null, + restrictCount: number, + restrictingSids: Buffer, + newToken: NativePtr, + ): number + setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + setNamedSecurityInfoW( + path: string, + objectType: number, + information: number, + owner: null, + group: null, + dacl: NativePtr | null, + sacl: null, + ): number + getNamedSecurityInfoW( + path: string, + objectType: number, + information: number, + owner: NativePtr, + group: NativePtr, + dacl: NativePtr, + sacl: NativePtr, + descriptor: NativePtr, + ): number + getTempPathW(length: number, buffer: Buffer): number + setEnvironmentVariableW(name: string, value: string): number + setConsoleCtrlHandler(handler: null, add: number): number + createFileW( + fileName: string, + desiredAccess: number, + shareMode: number, + attributes: null, + creationDisposition: number, + flagsAndAttributes: number, + templateFile: null, + ): NativePtr + lockFileEx( + file: NativePtr, + flags: number, + reserved: number, + bytesLow: number, + bytesHigh: number, + overlapped: NativePtr, + ): number + unlockFileEx( + file: NativePtr, + reserved: number, + bytesLow: number, + bytesHigh: number, + overlapped: NativePtr, + ): number } /** - * True for CreateFileW's INVALID_HANDLE_VALUE failure marker (-1, which - * koffi hands back as the unsigned 64-bit all-ones pointer). - * @param handle - the handle CreateFileW returned. - * @returns whether the handle signals failure. + * Return whether CreateFileW produced INVALID_HANDLE_VALUE. + * @param handle - handle returned by CreateFileW. + * @returns true for null, zero, or the all-bits-one sentinel. */ export function isInvalidHandle(handle: NativePtr | null | undefined): boolean { if (isNullPtr(handle)) return true return (handle as bigint) === 0xFFFFFFFFFFFFFFFFn || (handle as bigint) === -1n } -type Ptr = ReturnType - -/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ -export interface StartupInfoInput { - cb: number - dwFlags: number - hStdInput: NativePtr - hStdOutput: NativePtr - hStdError: NativePtr -} - -/** Decoded PROCESS_INFORMATION (layout verified: size 24). */ -export interface ProcessInfoOutput { - hProcess: NativePtr | null - hThread: NativePtr | null - dwProcessId: number - dwThreadId: number -} - -/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */ -export interface Win32Bindings { - // ---- process / token handles -------------------------------------------- - openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr - openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number - closeHandle(handle: NativePtr): number - // ---- errors / diagnostics ------------------------------------------------ - getLastError(): number - formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number - // ---- memory -------------------------------------------------------------- - localAlloc(flags: number, bytes: number): NativePtr - localFree(memory: NativePtr): NativePtr - // ---- SIDs ---------------------------------------------------------------- - convertStringSidToSidW(stringSid: string, sid: NativePtr): number - createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number - isValidSid(sid: NativePtr): number - getLengthSid(sid: NativePtr): number - copySid(length: number, destination: NativePtr, source: NativePtr): number - // ---- token information --------------------------------------------------- - getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number - setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number - // ---- restricted token ---------------------------------------------------- - createRestrictedToken( - existing: NativePtr, flags: number, - disableCount: number, disableSids: null, - deletePrivilegeCount: number, privilegesToDelete: null, - restrictCount: number, restrictingSids: Buffer, - newToken: NativePtr, - ): number - // ---- ACL editing --------------------------------------------------------- - setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number - setNamedSecurityInfoW( - path: string, objectType: number, information: number, - owner: null, group: null, dacl: NativePtr | null, sacl: null, - ): number - getNamedSecurityInfoW( - path: string, objectType: number, information: number, - owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, - ): number - // ---- environment / io ---------------------------------------------------- - getTempPathW(length: number, buffer: Buffer): number - createFileW( - fileName: string, desiredAccess: number, shareMode: number, attributes: null, - creationDisposition: number, flagsAndAttributes: number, templateFile: null, - ): NativePtr - lockFileEx(file: NativePtr, flags: number, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number - unlockFileEx(file: NativePtr, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number - createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number - setHandleInformation(handle: NativePtr, mask: number, flags: number): number - createProcessAsUserW( - token: NativePtr, applicationName: null, commandLine: string, - processAttributes: null, threadAttributes: null, - inheritHandles: number, creationFlags: number, environment: null, - currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, - ): number - setEnvironmentVariableW(name: string, value: string): number - readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number - peekNamedPipe( - pipe: NativePtr, buffer: null, size: number, - bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr, - ): number - waitForSingleObject(handle: NativePtr, milliseconds: number): number - getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number - resumeThread(thread: NativePtr): number - // ---- job object (runner kill-on-close) ----------------------------------- - createJobObjectW(attributes: null, name: null): NativePtr - setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number - assignProcessToJobObject(job: NativePtr, process: NativePtr): number - // Terminate a suspended child that could not be placed in the kill-on-close - // job — closing handles alone would leave it hanging forever. - terminateProcess(process: NativePtr, exitCode: number): number - // ---- console ------------------------------------------------------------- - // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): - // the runner survives console Ctrl+C so the child handles its own and the - // runner can clean up grants after the child exits. - setConsoleCtrlHandler(handler: null, add: number): number - getStdHandle(stdHandle: number): NativePtr -} - -const PVOID: Ptr = koffi.pointer('void') -const PPVOID: Ptr = koffi.pointer(PVOID) - -/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */ -export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { - cb: 'uint32', - lpReserved: 'str16', - lpDesktop: 'str16', - lpTitle: 'str16', - dwX: 'uint32', - dwY: 'uint32', - dwXSize: 'uint32', - dwYSize: 'uint32', - dwXCountChars: 'uint32', - dwYCountChars: 'uint32', - dwFillAttribute: 'uint32', - dwFlags: 'uint32', - wShowWindow: 'uint16', - cbReserved2: 'uint16', - lpReserved2: koffi.pointer('uint8'), - hStdInput: PVOID, - hStdOutput: PVOID, - hStdError: PVOID, -}) - -/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */ -export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { - hProcess: PVOID, - hThread: PVOID, - dwProcessId: 'uint32', - dwThreadId: 'uint32', -}) - -/* 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). - * @returns the allocated slot pointer. - */ -export function allocPtrSlot(): NativePtr { - const value: unknown = koffi.alloc(PVOID, 1) - return value as NativePtr -} - -/** - * Allocate one uint32 slot. - * @returns the allocated slot pointer. - */ -export function allocUint32(): NativePtr { - const value: unknown = koffi.alloc('uint32', 1) - return value as NativePtr -} - -/** - * Write a uint32 value into a slot pointer. - * @param slot - the slot allocated by {@link allocUint32}. - * @param value - the uint32 to encode. + * Encode a uint32 into an allocated slot. + * @param slot - slot allocated by allocUint32. + * @param value - unsigned value to store. */ export function encodeUint32(slot: NativePtr, value: number): void { koffi.encode(slot, 'uint32', value) } /** - * Decode the pointer stored in a pointer-sized slot (NULL becomes null). - * @param slot - the pointer-sized slot holding the out-parameter value. - * @returns the decoded pointer, or null for NULL. - */ -export function decodePtr(slot: NativePtr): NativePtr | null { - const value: unknown = koffi.decode(slot, PVOID) - if (isNullPtr(value as NativePtr | null | undefined)) return null - return value as NativePtr -} - -/** - * Decode a uint32 at a slot pointer. - * @param slot - the uint32 slot holding the out-parameter value. - * @returns the decoded uint32. - */ -export function decodeUint32(slot: NativePtr): number { - const value: unknown = koffi.decode(slot, 'uint32') - return value as number -} - -/** - * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). - * @param ptr - the koffi pointer. - * @returns the pointer's numeric address. + * Return a Koffi pointer's numeric address for struct packing. + * @param ptr - native pointer. + * @returns pointer address. */ export function ptrAddress(ptr: NativePtr): bigint { return koffi.address(ptr) } /** - * Allocate a raw byte block (used for SID copies and variable-length arrays). - * @param length - the block size in bytes. - * @returns the allocated block pointer. + * Allocate a raw byte block. + * @param length - byte count. + * @returns allocated pointer. */ export function allocBytes(length: number): NativePtr { - const value: unknown = koffi.alloc('uint8', length) - return value as NativePtr + return koffi.alloc('uint8', length) as NativePtr } /** - * Allocate one zeroed OVERLAPPED (32 bytes on x64: Internal@0, InternalHigh@8, - * Offset@16, OffsetHigh@20, hEvent@24). LockFileEx/UnlockFileEx receive this - * instead of a NULL lpOverlapped: koffi 3.1.1 crashes on NULL there, and a - * zeroed OVERLAPPED on a synchronous file handle is the documented equivalent - * (the byte range locks from offset 0, hEvent stays NULL). - * @returns the zeroed block pointer. + * Allocate one zeroed x64 OVERLAPPED record. + * @returns allocated pointer. */ export function allocOverlapped(): NativePtr { return allocBytes(32) } /** - * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). - * @param buffer - the buffer holding the pointer value. - * @param offset - byte offset of the pointer inside the buffer. - * @returns the decoded pointer, or null for NULL. + * Decode a pointer value from a Buffer field. + * @param buffer - encoded native record. + * @param offset - pointer field byte offset. + * @returns decoded pointer, or null for address zero. */ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { - const value: unknown = koffi.decode(buffer, offset, PVOID) - if (isNullPtr(value as NativePtr | null | undefined)) return null - return value as NativePtr + const value = koffi.decode(buffer, offset, PVOID) as NativePtr | null + return isNullPtr(value) ? null : value } /** - * Decode a uint8 at a native pointer plus byte offset — the ACL walk's - * field-read primitive (koffi.decode with an offset, no memcpy, no pointer - * arithmetic). - * @param ptr - the native pointer to read from. - * @param offset - byte offset from the pointer. - * @returns the decoded uint8. + * Decode a uint8 field at a native pointer offset. + * @param ptr - native record pointer. + * @param offset - field byte offset. + * @returns decoded value. */ export function decodeUint8At(ptr: NativePtr, offset: number): number { - const value: unknown = koffi.decode(ptr, offset, 'uint8') - return value as number + return koffi.decode(ptr, offset, 'uint8') as number } /** - * Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}). - * @param ptr - the native pointer to read from. - * @param offset - byte offset from the pointer. - * @returns the decoded uint16. + * Decode a uint16 field at a native pointer offset. + * @param ptr - native record pointer. + * @param offset - field byte offset. + * @returns decoded value. */ export function decodeUint16At(ptr: NativePtr, offset: number): number { - const value: unknown = koffi.decode(ptr, offset, 'uint16') - return value as number + return koffi.decode(ptr, offset, 'uint16') as number } /** - * Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}). - * @param ptr - the native pointer to read from. - * @param offset - byte offset from the pointer. - * @returns the decoded uint32. + * Decode a uint32 field at a native pointer offset. + * @param ptr - native record pointer. + * @param offset - field byte offset. + * @returns decoded value. */ export function decodeUint32At(ptr: NativePtr, offset: number): number { - const value: unknown = koffi.decode(ptr, offset, 'uint32') - return value as number + return koffi.decode(ptr, offset, 'uint32') as number } /** - * Compare two SIDs field-by-field via BOUNDED offset reads (revision, count, - * identifier authority, subauthorities up to the count) — never a fixed-size - * struct decode, which would read past a short SID allocation (a SID with - * fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible - * subauthority count reads as unequal. - * @param left - pointer to one SID (offset 0). - * @param leftOffset - byte offset of the SID structure within `left`. - * @param right - pointer to the other SID. - * @param rightOffset - byte offset of the SID structure within `right`. - * @returns whether the SIDs are identical. + * Compare two in-memory SID records without allocating strings. + * @param left - first native buffer. + * @param leftOffset - first SID byte offset. + * @param right - second native buffer. + * @param rightOffset - second SID byte offset. + * @returns true when revision, authority, and every sub-authority match. */ -export function sameSidAt(left: NativePtr, leftOffset: number, right: NativePtr, rightOffset: number): boolean { - const leftRevision = decodeUint8At(left, leftOffset) - const rightRevision = decodeUint8At(right, rightOffset) - if (leftRevision !== rightRevision) return false +export function sameSidAt( + left: NativePtr, + leftOffset: number, + right: NativePtr, + rightOffset: number, +): boolean { + if (decodeUint8At(left, leftOffset) !== decodeUint8At(right, rightOffset)) return false const leftCount = decodeUint8At(left, leftOffset + 1) const rightCount = decodeUint8At(right, rightOffset + 1) if (leftCount !== rightCount || leftCount > abi.SID_MAX_SUB_AUTHORITIES) return false - for (let index = 0; index < 6; index++) { - if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false + for (let index = 0; index < 6; index += 1) { + if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) { + return false + } } - for (let index = 0; index < leftCount; index++) { - if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false + for (let index = 0; index < leftCount; index += 1) { + if (decodeUint32At(left, leftOffset + 8 + index * 4) !== + decodeUint32At(right, rightOffset + 8 + index * 4)) return false } return true } -/** - * Allocate a zeroed STARTUPINFOW. - * @returns the allocated struct pointer. - */ -export function allocStartupInfo(): NativePtr { - const value: unknown = koffi.alloc(STARTUPINFOW, 1) - return value as NativePtr -} - -/** - * Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). - * @param startupInfo - the allocated STARTUPINFOW to encode into. - * @param fields - the field subset to write. - */ -export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { - koffi.encode(startupInfo, STARTUPINFOW, fields) -} - -/** - * Allocate a zeroed PROCESS_INFORMATION. - * @returns the allocated struct pointer. - */ -export function allocProcessInfo(): NativePtr { - const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) - return value as NativePtr -} - -/** - * Decode a PROCESS_INFORMATION after CreateProcessAsUserW. - * @param processInfo - the PROCESS_INFORMATION filled by the spawn call. - * @returns the decoded handle/id fields. - */ -export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { - const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) - return value as ProcessInfoOutput -} - let cached: Win32Bindings | undefined function bindings(): Win32Bindings { if (cached !== undefined) return cached - const kernel32 = koffi.load('kernel32.dll') - const advapi32 = koffi.load('advapi32.dll') - - // Each binding shape is verified by verify/abi-probe.cpp against the real - // Windows headers and exercised end-to-end by tests/probe.spec.ts; the - // single cast keeps the per-binding noise out of this table. - const bind = (lib: ReturnType, name: string, result: Ptr | string, args: Array): unknown => - lib.func('__stdcall', name, result, args) - - cached = { + cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({ openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), - closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), - getLastError: bind(kernel32, 'GetLastError', 'uint32', []), - formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', ['uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID]), localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), - createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), + createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', [ + 'int', PVOID, PVOID, koffi.pointer('uint32'), + ]), isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), - getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), - setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']), - createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), - setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), - setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), - getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), - getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), - // fileapi.h line ~64: HANDLE CreateFileW(LPCWSTR, DWORD, DWORD, - // LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE). - createFileW: bind(kernel32, 'CreateFileW', PVOID, ['str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID]), - // fileapi.h lines ~177/~185: BOOL LockFileEx(HANDLE, DWORD, DWORD, DWORD, - // DWORD, LPOVERLAPPED); BOOL UnlockFileEx(HANDLE, DWORD, DWORD, DWORD, - // LPOVERLAPPED). lpOverlapped is NULL for synchronous locking. - lockFileEx: bind(kernel32, 'LockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID]), - unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), - createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), - setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), - createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ - PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', - koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [ + PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32'), ]), + setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']), + createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [ + PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID, + ]), + setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', [ + 'str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID, + ]), + getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', [ + 'str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID, + ]), + getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']), - readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), - peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), - waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), - getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), - resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), - createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), - setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), - assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), - terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), - getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), - } as unknown as Win32Bindings + createFileW: bind(kernel32, 'CreateFileW', PVOID, [ + 'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID, + ]), + lockFileEx: bind(kernel32, 'LockFileEx', 'int', [ + PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID, + ]), + unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [ + PVOID, 'uint32', 'uint32', 'uint32', PVOID, + ]), + })) as unknown as Win32Bindings return cached } /** - * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). - * @returns the cached binding table. + * Resolve the cached ACL/token binding table asynchronously. + * @returns generic process plus ACL/token bindings. */ export function win32(): Promise { return Promise.resolve(bindings()) } /** - * Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's - * server-side per-session grant materializes ACEs inside the synchronous - * `confine()` call, which cannot await. Same cached table as {@link win32} - * (the underlying koffi loads are synchronous; the async wrapper exists for - * the runner's await-shaped call sites). - * @returns the cached binding table. + * Resolve the cached ACL/token binding table synchronously. + * @returns generic process plus ACL/token bindings. */ export function win32Sync(): Win32Bindings { return bindings() } /** - * Turn a Win32 error code into readable text via FormatMessageW. - * @param api - the binding table. - * @param win32Code - the error code to format. - * @returns the formatted message text, or '' when formatting fails. - */ -export function errorText(api: Win32Bindings, win32Code: number): string { - const buffer = Buffer.alloc(1024) - const length = api.formatMessageW( - abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, - null, win32Code, 0, buffer, buffer.length / 2, null, - ) - if (length === 0) return '' - return buffer.subarray(0, length * 2).toString('utf16le').trim() -} - -/** - * Read the process temp directory via GetTempPathW (fileapi.h line ~188). - * Defensive against an overlong system temp path: GetTempPathW reports the - * REQUIRED length (including NUL) without writing the buffer when it is too - * small, so a reported length beyond the buffer's capacity means the buffer - * was never filled and must not be decoded. - * @param api - the binding table. - * @returns the NUL-terminated temp path decoded as a string. + * Resolve the current Windows temporary directory. + * @param api - active ACL/token binding table. + * @returns UTF-16 path reported by GetTempPathW. */ export function getTempPath(api: Win32Bindings): string { const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) const length = api.getTempPathW(buffer.length / 2, buffer) if (length === 0) throwLastError(api, 'GetTempPathW') if (length > buffer.length / 2) { - throw new Win32Error('GetTempPathW', abi.ERROR_INSUFFICIENT_BUFFER, `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`) + throw new Win32Error( + 'GetTempPathW', + ERROR_INSUFFICIENT_BUFFER, + `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`, + ) } return buffer.subarray(0, length * 2).toString('utf16le') } - -/** - * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately - * after the failed call so GetLastError is not clobbered by other Win32 calls. - * @param api - the binding table. - * @param name - the failed API's name for the error message. - * @param detail - optional detail overriding the formatted system message. - * @returns never — always throws. - */ -export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { - const win32Code = api.getLastError() - throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) -} - -/** - * Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). - * @param api - the binding table. - * @param name - the failed API's name for the error message. - * @param win32Code - the API's returned error code. - * @param detail - optional detail overriding the formatted system message. - * @returns never — always throws. - */ -export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { - throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) -} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index cf304b1904..40d0d47a1d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -42,9 +42,9 @@ import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' +import { closeHandleChecked, Win32Error } from '@deepseek-ai/dsh-win32-process' import { grantWrite, revokeWrite } from './acl.ts' -import { Win32Error } from './errors.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { assertPrivateTempDisjoint } from './path-boundary.ts' @@ -52,11 +52,9 @@ import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' -export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' -export { Win32Error } from './errors.ts' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { @@ -357,15 +355,27 @@ export class AclSandbox { if (options.stdio === 'inherit') { const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) - let exitCodePromise: Promise | undefined + let settlement: Promise | undefined return { pid: native.pid, - wait: async () => { - exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) - const exitCode = await exitCodePromise - if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. + wait: () => (settlement ??= (async () => { + const failures: unknown[] = [] + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + try { + closeHandleChecked(api, native.job, 'kill-on-close job') + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } - }, + })()), } } @@ -376,15 +386,27 @@ export class AclSandbox { // the thread and would starve the drains while the child is still running // (pipe-buffer deadlock). The drains resolve only after the child closed // its pipe ends — by then the wait returns immediately. - let exitCodePromise: Promise | undefined + let settlement: Promise | undefined return { pid: native.pid, - wait: async () => { - const stdoutBuffer = await stdout - const stderrBuffer = await stderr - exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) - return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } - }, + wait: () => (settlement ??= (async () => { + const drains = await Promise.allSettled([stdout, stderr]) + const failures = drains.flatMap(outcome => + outcome.status === 'rejected' ? [outcome.reason as unknown] : []) + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') + return { + stdout: (drains[0] as PromiseFulfilledResult).value, + stderr: (drains[1] as PromiseFulfilledResult).value, + exitCode, + } + })()), } } diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index eafcb252ce..a36b0253c4 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -1,357 +1,60 @@ -/** - * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with - * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then - * asynchronous pipe draining and exit waiting. Console isolation - * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this - * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED - * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is - * pipe-based and unaffected; the child shares the host console. - * @module @deepseek-ai/dsh-sandbox-windows-acl/spawn - */ +/** Restricted-token adapters over the shared Win32 process owner. */ -import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts' -import type { NativePtr, Win32Bindings } from './ffi.ts' -import * as abi from './win32-abi.ts' +import { + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from '@deepseek-ai/dsh-win32-process' +import type { + NativePtr, + SpawnedJobProcess, + SpawnedPipedProcess, +} from '@deepseek-ai/dsh-win32-process' +import type { Win32Bindings } from './ffi.ts' + +export { drainPipe } from '@deepseek-ai/dsh-win32-process' + +/** Restricted-token child with piped stdio resources. */ +export interface SpawnedNative extends SpawnedPipedProcess {} +/** Restricted-token child assigned to a kill-on-close Job. */ +export interface SpawnedInherited extends SpawnedJobProcess {} /** - * Quote one argument per the CommandLineToArgvW parsing rules: backslashes - * are doubled only before a quote character — including the closing quote - * this function appends, so a trailing backslash run is doubled as well - * (otherwise an odd run would escape the closing quote into a literal - * character and corrupt the rest of the command line). Mirrors the CRT - * ArgvQuote behavior Microsoft documents for command-line arguments. - * @param argument - one argv entry to quote. - * @returns the quoted entry (bare when quoting is unnecessary). - */ -export function quoteArg(argument: string): string { - if (argument === '') return '""' - if (!/[\s"]/u.test(argument)) return argument - let quoted = '"' - for (let index = 0; index < argument.length; index++) { - let backslashes = 0 - while (index < argument.length && argument.charAt(index) === '\\') { - backslashes++ - index++ - } - if (index === argument.length) { - // Trailing backslash run: doubled so it cannot escape the closing quote. - quoted += '\\'.repeat(backslashes * 2) - } else if (argument.charAt(index) === '"') { - quoted += '\\'.repeat(backslashes * 2 + 1) + '"' - } else { - quoted += '\\'.repeat(backslashes) + argument.charAt(index) - } - } - return quoted + '"' -} - -/** - * Build the single command line CreateProcess parses from program + argv. - * @param program - the executable (argv[0]). - * @param args - the remaining argv entries. - * @returns the joined, quoted command line. - */ -export function buildCommandLine(program: string, args: readonly string[]): string { - return [program, ...args].map(quoteArg).join(' ') -} - -interface PipePair { - read: NativePtr - write: NativePtr -} - -function createPipe(api: Win32Bindings): PipePair { - const readSlot = allocPtrSlot() - const writeSlot = allocPtrSlot() - if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') - const read = decodePtr(readSlot) - const write = decodePtr(writeSlot) - if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle') - return { read, write } -} - -function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void { - if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { - throwLastError(api, 'SetHandleInformation', label) - } -} - -/** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */ -export interface SpawnedNative { - pid: number - process: NativePtr - stdoutRead: NativePtr - stderrRead: NativePtr -} - -/** - * Create a process under the restricted token with piped stdio. The child's - * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends - * are returned for draining. The child inherits the caller's environment block - * (lpEnvironment NULL); the caller rewrites entries through - * SetEnvironmentVariableW before spawning (the runner's per-session temp - * contract) — passing an explicit block through koffi trips - * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically). - * @param api - the binding table. - * @param token - the restricted token the child runs under. + * Spawn a restricted-token child with piped stdout/stderr. + * @param api - ACL/token binding table. + * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns the spawned child's handles. + * @returns process and caller-owned pipe handles. */ export function spawnSandboxed( api: Win32Bindings, token: NativePtr, options: { command: string; args: readonly string[]; cwd: string }, ): SpawnedNative { - const stdIn = createPipe(api) - const stdOut = createPipe(api) - const stdErr = createPipe(api) - // Child side of each pipe must be inheritable (POC lines 262-268). - setInheritable(api, stdIn.read, 'stdin read end') - setInheritable(api, stdOut.write, 'stdout write end') - setInheritable(api, stdErr.write, 'stderr write end') - - const startupInfo = allocStartupInfo() - encodeStartupInfo(startupInfo, { - cb: abi.STARTUPINFOW_SIZE, - dwFlags: abi.STARTF_USESTDHANDLES, - hStdInput: stdIn.read, - hStdOutput: stdOut.write, - hStdError: stdErr.write, - }) - - const processInfo = allocProcessInfo() - const commandLine = buildCommandLine(options.command, options.args) - const created = api.createProcessAsUserW( - token, null, commandLine, - null, null, - 1, // bInheritHandles: required for redirection - 0, // no creation flags: suspended/no-window variants are unusable under the restriction - null, options.cwd, - startupInfo, processInfo, - ) - // Capture the failure before CloseHandle calls clobber GetLastError, then - // close every pipe handle created so far — the six-close contract this test - // surface pins (tests/failure-paths.spec.ts). - if (created === 0) { - const win32Code = api.getLastError() - api.closeHandle(stdIn.read) - api.closeHandle(stdIn.write) - api.closeHandle(stdOut.read) - api.closeHandle(stdOut.write) - api.closeHandle(stdErr.read) - api.closeHandle(stdErr.write) - throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) - } - - const info = decodeProcessInfo(processInfo) - const processHandle = info.hProcess - const threadHandle = info.hThread - if (processHandle === null || threadHandle === null) { - throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) - } - - // Host-side cleanup: child handles are now duplicated in the child; the - // host closes its copies so ReadFile sees EOF when the child exits. - api.closeHandle(stdIn.read) - api.closeHandle(stdOut.write) - api.closeHandle(stdErr.write) - api.closeHandle(stdIn.write) - api.closeHandle(threadHandle) - - return { - pid: info.dwProcessId, - process: processHandle, - stdoutRead: stdOut.read, - stderrRead: stdErr.read, - } + return spawnPipedProcess(api, { ...options, token }) } /** - * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. - * @param api - the binding table. - * @param handle - the pipe read end to drain (closed when done). - * @returns the complete pipe contents. - */ -export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { - const chunks: Buffer[] = [] - for (;;) { - const bytesReadSlot = allocUint32() - const totalAvailSlot = allocUint32() - const leftThisMessageSlot = allocUint32() - const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot) - if (peeked === 0) { - const win32Code = api.getLastError() - if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF - throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) - } - const available = decodeUint32(totalAvailSlot) - if (available > 0) { - const chunk = Buffer.alloc(available) - const readSlot = allocUint32() - if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) { - throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) - } - chunks.push(chunk.subarray(0, decodeUint32(readSlot))) - } - // Small backoff instead of setImmediate: a bare next-tick would busy-poll - // the pipe at full event-loop speed while the child produces no output. - await new Promise(resolve => setTimeout(resolve, 1)) - } - api.closeHandle(handle) - return Buffer.concat(chunks) -} - -/** - * Wait for process exit and return its exit code. Call only after both drains - * have resolved — the drains finish when the child closed its pipe ends, i.e. - * the child has already exited, so this wait returns immediately. Calling it - * earlier would block the event loop and starve the drains (the pipe-buffer - * deadlock the POC comments warn about). - * @param api - the binding table. - * @param process - the child process handle (closed when done). - * @returns the child's exit code. - */ -export function waitForExit(api: Win32Bindings, process: NativePtr): number { - const waitResult = api.waitForSingleObject(process, abi.INFINITE) - if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') - const exitCodeSlot = allocUint32() - if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') - api.closeHandle(process) - return decodeUint32(exitCodeSlot) -} - -/** - * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at - * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout - * verified by abi-probe.cpp). When the caller dies with the job handle open, - * Windows terminates every process in the job — the orphan-child backstop. - * The caller keeps the returned handle open for the child's lifetime. - */ -function createKillOnCloseJob(api: Win32Bindings): NativePtr { - const job = api.createJobObjectW(null, null) - if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') - const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) - information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET) - if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) { - const win32Code = api.getLastError() - api.closeHandle(job) - throwWin32(api, 'SetInformationJobObject', win32Code) - } - return job -} - -/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */ -export interface SpawnedInherited { - pid: number - process: NativePtr - /** Kill-on-close job the child was placed in; caller closes it after the child exits. */ - job: NativePtr -} - -/** - * Create a process under the restricted token whose stdio passes straight - * through to the caller's pipes. This is the runner shape: the harness spawns - * the runner with piped stdio, and the runner's confined child writes to - * those same pipes. - * - * Node clears the inheritability of its stdio handles at startup - * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit - * bit around the call (libuv instead duplicates the handles; re-enabling is - * equivalent here and cheaper) and pass them explicitly via - * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles - * ("The handle is invalid", verified the hard way). The child starts - * suspended so it can be assigned to a kill-on-close job before it runs. - * @param api - the binding table. - * @param token - the restricted token the child runs under. + * Spawn a restricted-token child in a kill-on-close Job with inherited stdio. + * @param api - ACL/token binding table. + * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns the spawned child's handles and job. + * @returns process and Job handles after assignment and resume. */ export function spawnSandboxedInherited( api: Win32Bindings, token: NativePtr, options: { command: string; args: readonly string[]; cwd: string }, ): SpawnedInherited { - const job = createKillOnCloseJob(api) - const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE) - const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE) - const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE) - if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) { - api.closeHandle(job) - throwLastError(api, 'GetStdHandle', 'null standard handle') - } - - const makeInheritable = (handle: NativePtr, label: string): void => { - if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { - throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) - } - } - const restoreInherit = (handle: NativePtr): void => { - // Best-effort hygiene: the runner spawns nothing else; failures here must - // not mask the child outcome, so the result is deliberately unchecked. - api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) - } - makeInheritable(stdIn, 'stdin') - makeInheritable(stdOut, 'stdout') - makeInheritable(stdErr, 'stderr') - - const startupInfo = allocStartupInfo() - encodeStartupInfo(startupInfo, { - cb: abi.STARTUPINFOW_SIZE, - dwFlags: abi.STARTF_USESTDHANDLES, - hStdInput: stdIn, - hStdOutput: stdOut, - hStdError: stdErr, - }) - - const processInfo = allocProcessInfo() - const commandLine = buildCommandLine(options.command, options.args) - const created = api.createProcessAsUserW( - token, null, commandLine, - null, null, - 1, // bInheritHandles: the re-enabled std handles must be inheritable - abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution - null, options.cwd, - startupInfo, processInfo, - ) - restoreInherit(stdIn) - restoreInherit(stdOut) - restoreInherit(stdErr) - if (created === 0) { - const win32Code = api.getLastError() - api.closeHandle(job) - throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) - } - - const info = decodeProcessInfo(processInfo) - const processHandle = info.hProcess - const threadHandle = info.hThread - if (processHandle === null || threadHandle === null) { - api.closeHandle(job) - throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) - } - - if (api.assignProcessToJobObject(job, processHandle) === 0) { - // The child was created suspended and is NOT in the kill-on-close job: - // closing handles would leave it suspended forever. Terminate it first, - // then drop the handles and throw. - const win32Code = api.getLastError() - api.terminateProcess(processHandle, 1) - api.closeHandle(threadHandle) - api.closeHandle(processHandle) - api.closeHandle(job) - throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) - } - if (api.resumeThread(threadHandle) === 0xFFFFFFFF) { - // Closing the job triggers kill-on-close, so the suspended child dies - // instead of hanging until this process exits; the process/thread handles - // must go too. - const win32Code = api.getLastError() - api.closeHandle(threadHandle) - api.closeHandle(processHandle) - api.closeHandle(job) - throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) - } - api.closeHandle(threadHandle) - - return { pid: info.dwProcessId, process: processHandle, job } + return spawnInheritedJobProcess(api, { ...options, token }) +} + +/** + * Wait for a restricted child and close its process handle. + * @param api - ACL/token binding table. + * @param process - caller-owned process handle. + * @returns direct process exit code. + */ +export function waitForExit(api: Win32Bindings, process: NativePtr): number { + return waitForProcessExit(api, process) } diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 5af4496af7..019f894a6c 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -1,258 +1,94 @@ -/** - * Windows ABI constants for the ACL-sandbox backend. - * - * Every value was verified against the actual MinGW Windows headers on this - * machine (C:\Strawberry\c\x86_64-w64-mingw32\include\) and cross-checked at - * runtime by verify/abi-probe.cpp (same numbers; static_asserts passed). - * Regenerate the probe with: - * g++ -std=c++20 -municode -O2 -o abi-probe.exe abi-probe.cpp -ladvapi32 && .\abi-probe.exe - * - * The port intentionally excludes two pieces of the original POC - * (github.com/huoyaoyuan/windows-acl-restrict-poc @ 10e4dfb), both verified - * empirically on Windows 11 build 26200: - * - S-1-2-1 (console logon SID) in the restricting list: the POC created it - * via CreateWellKnownSid(WinLocalLogonSid) which fails here with - * ERROR_INVALID_PARAMETER (87), leaving a garbage SID that makes - * CreateRestrictedToken fail with ERROR_INVALID_SID (1337); using the - * correct WinConsoleLogonSid does produce a valid S-1-2-1, but the child - * then still dies with STATUS_DLL_INIT_FAILED (0xC0000142) whenever - * CREATE_NO_WINDOW / CREATE_NEW_CONSOLE is used. - * - Console isolation: under this restriction scheme a hidden console is not - * attainable, so children share the host console (stdio redirection is - * pipe-based and unaffected). - * @module @deepseek-ai/dsh-sandbox-windows-acl/win32-abi - */ +/** ACL/token-specific Win32 constants. */ -// ---- winnt.h --------------------------------------------------------------- - -// TOKEN_* access rights (winnt.h lines ~3928) -/** TOKEN_ASSIGN_PRIMARY: required to create a process with the token (CreateProcessAsUser). */ -export const TOKEN_ASSIGN_PRIMARY = 0x0001 -/** TOKEN_DUPLICATE: required to duplicate a token (DuplicateTokenEx). */ -export const TOKEN_DUPLICATE = 0x0002 -/** TOKEN_QUERY: required to read token information (GetTokenInformation). */ -export const TOKEN_QUERY = 0x0008 -/** TOKEN_ADJUST_DEFAULT: required to change a token's default DACL. */ -export const TOKEN_ADJUST_DEFAULT = 0x0080 - -// SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) -/** - * SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with - * `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number). - */ -export const SE_GROUP_LOGON_ID = 0xC0000000 - -// Generic file access (winnt.h lines ~5893-5913): -// FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES -// | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE -/** STANDARD_RIGHTS_WRITE (== READ_CONTROL): the standard-rights component of generic write access. */ -export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL -/** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ -export const FILE_GENERIC_WRITE = 0x00120116 -/** DELETE: remove or rename the object (winnt.h line ~3009). */ -export const DELETE = 0x00010000 -/** FILE_DELETE_CHILD: remove or rename a directory's children (winnt.h line ~5907). */ -export const FILE_DELETE_CHILD = 0x0040 -// The POC granted FILE_GENERIC_WRITE minus READ_CONTROL, which displays as -// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). The -// sandbox grant adds DELETE and FILE_DELETE_CHILD so confined -// delete/rename/git operations inside the granted trees pass the token's -// access check too; Write+DELETE displays as "Modify" in icacls. -// WRITE_DAC/WRITE_OWNER stay OUT deliberately — granting them would let the -// child take ownership or rewrite DACLs and escape the allowlist (the -// security boundary). -/** - * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and - * FILE_DELETE_CHILD — the write+delete access mask the capability-SID ACEs grant - * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are - * deliberately excluded: they would let the confined child take ownership or - * rewrite DACLs. - */ -export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156 - -/** - * FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE - * | 0x1FF): full file-object access. The mask of the ACE merged into the - * restricted token's DEFAULT DACL — the token holder must keep full access to - * every NEW object it creates (pipes included), and the ACE must name a - * restricting SID so the write pass-2 check passes at creation. - */ -export const FILE_ALL_ACCESS = 0x1F01FF - -// CreateRestrictedToken flags (winnt.h lines ~4284) -/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ -export const DISABLE_MAX_PRIVILEGE = 0x1 -/** LUA_TOKEN: produce a limited-user (filtered admin) token. */ -export const LUA_TOKEN = 0x4 -/** WRITE_RESTRICTED: intersect write access with the restricting SIDs' ACL grants — the sandbox's core mechanism. */ -export const WRITE_RESTRICTED = 0x8 - -// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) -/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */ -export const WinWorldSid = 1 - -// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) -/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ -export const TokenGroups = 2 -/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */ -export const TokenDefaultDacl = 6 - -// SECURITY_INFORMATION (winnt.h line ~4293) -/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ -export const DACL_SECURITY_INFORMATION = 0x00000004 - -// PROCESS access rights (winnt.h lines ~4364) -/** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */ +/** OpenProcess access required to query the current process token. */ export const PROCESS_QUERY_INFORMATION = 0x0400 - -// ---- accctrl.h ------------------------------------------------------------- - -// SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) -/** SE_FILE_OBJECT: the trustee path names a filesystem object. */ +/** Token right required by CreateProcessAsUserW. */ +export const TOKEN_ASSIGN_PRIMARY = 0x0001 +/** Token right required by DuplicateTokenEx. */ +export const TOKEN_DUPLICATE = 0x0002 +/** Token right required to read token information. */ +export const TOKEN_QUERY = 0x0008 +/** Token right required to replace the token default DACL. */ +export const TOKEN_ADJUST_DEFAULT = 0x0080 +/** Group attribute identifying the token logon SID. */ +export const SE_GROUP_LOGON_ID = 0xC0000000 +/** Standard-rights portion excluded from the write capability grant. */ +export const STANDARD_RIGHTS_WRITE = 0x00020000 +/** Generic file write access bits. */ +export const FILE_GENERIC_WRITE = 0x00120116 +/** Delete or rename an object. */ +export const DELETE = 0x00010000 +/** Delete or rename a directory child. */ +export const FILE_DELETE_CHILD = 0x0040 +/** Capability-SID access mask granting write, delete, and child deletion. */ +export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE +/** Full access used in the restricted token default DACL. */ +export const FILE_ALL_ACCESS = 0x1F01FF +/** CreateRestrictedToken flag that disables maximum privileges. */ +export const DISABLE_MAX_PRIVILEGE = 0x1 +/** CreateRestrictedToken limited-user flag. */ +export const LUA_TOKEN = 0x4 +/** Restrict write access to the listed restricting SIDs. */ +export const WRITE_RESTRICTED = 0x8 +/** WELL_KNOWN_SID_TYPE value for Everyone. */ +export const WinWorldSid = 1 +/** TOKEN_INFORMATION_CLASS value for token groups. */ +export const TokenGroups = 2 +/** TOKEN_INFORMATION_CLASS value for the token default DACL. */ +export const TokenDefaultDacl = 6 +/** SECURITY_INFORMATION flag selecting the DACL. */ +export const DACL_SECURITY_INFORMATION = 0x00000004 +/** SE_OBJECT_TYPE value for filesystem objects. */ export const SE_FILE_OBJECT = 1 - -// TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 -/** TRUSTEE_IS_UNKNOWN: TRUSTEE_TYPE unknown (TrusteeForm carries the shape). */ +/** TRUSTEE_TYPE value used when trustee classification is unknown. */ export const TRUSTEE_IS_UNKNOWN = 0 -/** TRUSTEE_IS_SID: TRUSTEE_FORM — Trustee.ptstrName is a SID pointer. */ +/** TRUSTEE_FORM value indicating a SID pointer. */ export const TRUSTEE_IS_SID = 0 -/** NO_MULTIPLE_TRUSTEE: Trustee.pMultipleTrustee is null. */ +/** Trustee record has no chained trustee. */ export const NO_MULTIPLE_TRUSTEE = 0 - -// ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) -/** GRANT_ACCESS: SetEntriesInAclW adds the entry as an allow ACE. */ +/** EXPLICIT_ACCESS mode that grants access. */ export const GRANT_ACCESS = 1 -/** REVOKE_ACCESS: SetEntriesInAclW removes the matching allow ACE. */ +/** EXPLICIT_ACCESS mode that revokes access. */ export const REVOKE_ACCESS = 4 - -// grfInheritance (accctrl.h lines ~137-142) -/** - * SUB_CONTAINERS_AND_OBJECTS_INHERIT: the ACE applies to the directory, its - * subdirectories, and files (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE). - */ -export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE - -// ---- winbase.h ------------------------------------------------------------- - -/** - * STARTF_USESTDHANDLES: STARTUPINFOW dwFlags — the child uses the hStd* - * handles, required because Node clears stdio inheritability at startup. - */ -export const STARTF_USESTDHANDLES = 0x00000100 -/** HANDLE_FLAG_INHERIT: SetHandleInformation flag re-enabling handle inheritance for the spawned child's stdio handles. */ -export const HANDLE_FLAG_INHERIT = 0x1 -/** INFINITE: never-timeout wait value. */ -export const INFINITE = 0xFFFFFFFF -/** MAX_PATH: legacy path length bound. */ +/** ACE inheritance flags for child containers and objects. */ +export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 +/** Legacy Win32 maximum path character count used by GetTempPathW. */ export const MAX_PATH = 260 -// winbase.h line ~410: the confined child starts suspended so the runner can -// assign it to the kill-on-close job before any of its code runs. -/** CREATE_SUSPENDED: create the child with its primary thread suspended until ResumeThread. */ -export const CREATE_SUSPENDED = 0x4 -// winbase.h lines ~497-499: GetStdHandle selectors. -/** STD_INPUT_HANDLE: GetStdHandle selector for the standard input. */ -export const STD_INPUT_HANDLE = -10 -/** STD_OUTPUT_HANDLE: GetStdHandle selector for the standard output. */ -export const STD_OUTPUT_HANDLE = -11 -/** STD_ERROR_HANDLE: GetStdHandle selector for the standard error. */ -export const STD_ERROR_HANDLE = -12 - -// FormatMessageW flags (winbase.h lines ~1446-1469) -/** FORMAT_MESSAGE_FROM_SYSTEM: format the message from the system message table. */ -export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 -/** FORMAT_MESSAGE_IGNORE_INSERTS: skip insert-sequence substitution. */ -export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 - -// ---- error codes ----------------------------------------------------------- - -/** ERROR_SUCCESS: the operation succeeded. */ +/** Successful Win32 status code. */ export const ERROR_SUCCESS = 0 -/** ERROR_INSUFFICIENT_BUFFER: a size-probe call succeeded but needs a larger buffer. */ -export const ERROR_INSUFFICIENT_BUFFER = 122 -/** ERROR_BROKEN_PIPE: the pipe's other end has closed. */ -export const ERROR_BROKEN_PIPE = 109 -/** ERROR_NO_DATA: the pipe is being closed. */ -export const ERROR_NO_DATA = 232 -/** ERROR_LOCK_VIOLATION: a byte-range lock conflicts with an existing lock (winerror.h line ~78). */ +/** Win32 error reported when an immediate byte-range lock cannot be obtained. */ export const ERROR_LOCK_VIOLATION = 33 - -// ---- lock files (fileapi.h / minwinbase.h / winnt.h) ----------------------- - -// CreateFileW dwDesiredAccess for the ACL lock files: plain read+write is -// enough to take byte-range locks. -/** GENERIC_READ: generic read access (winnt.h line ~3028). */ +/** Generic read access bit. */ export const GENERIC_READ = 0x80000000 -/** GENERIC_WRITE: generic write access (winnt.h line ~3029). */ +/** Generic write access bit. */ export const GENERIC_WRITE = 0x40000000 -// CreateFileW dwShareMode: the lock file is shared for read/write but NOT -// for delete — if a locked file could be deleted and recreated underneath the -// lock holder, two processes could hold "the same" lock on different files. -/** FILE_SHARE_READ: other opens may read (winnt.h line ~5949). */ +/** CreateFile share-read flag. */ export const FILE_SHARE_READ = 0x00000001 -/** FILE_SHARE_WRITE: other opens may write (winnt.h line ~5950). */ +/** CreateFile share-write flag. */ export const FILE_SHARE_WRITE = 0x00000002 -/** FILE_SHARE_DELETE: other opens may delete (winnt.h line ~5951) — deliberately NOT used for lock files. */ +/** CreateFile share-delete flag. */ export const FILE_SHARE_DELETE = 0x00000004 -/** OPEN_ALWAYS: create the lock file if absent, open it otherwise (fileapi.h line ~21). */ +/** CreateFile disposition that opens or creates the file. */ export const OPEN_ALWAYS = 4 -// LockFileEx dwFlags (minwinbase.h lines ~180-181, included by winbase.h). -/** LOCKFILE_EXCLUSIVE_LOCK: request an exclusive byte-range lock. */ +/** LockFileEx exclusive-lock flag. */ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 -/** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ +/** LockFileEx immediate-failure flag. */ export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 - -// ACE_HEADER.AceType (winnt.h lines ~3449-3463) -/** ACCESS_ALLOWED_ACE_TYPE: an access-allowed ACE granting the mask to the trustee. */ +/** ACE type for an allowed-access entry. */ export const ACCESS_ALLOWED_ACE_TYPE = 0 - -// SID structure (winnt.h line ~280 SID_IDENTIFIER_AUTHORITY; line ~286 -// #define SID_MAX_SUB_AUTHORITIES 15). -/** SID_MAX_SUB_AUTHORITIES: the most subauthorities a SID may carry. */ +/** Maximum SID sub-authority count. */ export const SID_MAX_SUB_AUTHORITIES = 15 - -// ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when -// reading a DACL are marked with this bit and are not part of the explicit -// DACL edits this module makes. -/** INHERITED_ACE: the ACE was inherited from the parent object, not stored explicitly. */ +/** ACE flag marking inherited entries. */ export const INHERITED_ACE = 0x10 - -// ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- - -// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last -// job handle closes — the orphan-child backstop for the runner design. -/** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */ -export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 -// JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. -/** JobObjectExtendedLimitInformation: JOBOBJECTINFOCLASS for the extended limit structure. */ -export const JobObjectExtendedLimitInformation = 9 -// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. -/** sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. */ -export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 -// LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION -// (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), -// verified by abi-probe. -/** - * LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION - * (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + - * PerJobUserTimeLimit@8), verified by abi-probe. - */ -export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 - -// ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- - -/** SECURITY_MAX_SID_SIZE: maximum SID byte size. */ +/** Maximum SID allocation size in bytes. */ export const SECURITY_MAX_SID_SIZE = 68 -/** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ +/** x64 SID_AND_ATTRIBUTES byte size. */ export const SID_AND_ATTRIBUTES_SIZE = 16 -/** TOKEN_GROUPS.Groups[] starts at offset 8 (GroupCount @0 + alignment). */ +/** x64 TOKEN_GROUPS offset of the first group entry. */ export const TOKEN_GROUPS_OFFSET = 8 -/** sizeof(EXPLICIT_ACCESS_W): perms@0 mode@4 inheritance@8 Trustee@16. */ +/** x64 EXPLICIT_ACCESS_W byte size. */ export const EXPLICIT_ACCESS_W_SIZE = 48 -/** Trustee offset inside EXPLICIT_ACCESS_W. */ +/** x64 offset of TRUSTEE_W inside EXPLICIT_ACCESS_W. */ export const TRUSTEE_W_OFFSET = 16 -/** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ +/** x64 offset of ptstrName inside TRUSTEE_W. */ export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 -/** sizeof(STARTUPINFOW), verified by abi-probe. */ -export const STARTUPINFOW_SIZE = 104 -/** sizeof(PROCESS_INFORMATION), verified by abi-probe. */ -export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index 005f522fda..fce0c3562a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -1,6 +1,6 @@ /** - * 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 + * ACL failure-path tests with minimal stub binding tables: 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 @@ -9,13 +9,13 @@ */ import { tmpdir } from 'node:os' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' 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') diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 903f56afc3..02dbbb82b4 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -1,18 +1,17 @@ /** - * FFI helper tests with stub binding tables (the failure-paths.spec.ts - * pattern): error formatting and temp-path decoding defenses, the + * Sandbox-specific FFI tests with stub binding tables: temp-path decoding, * 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 { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { Win32Error } from '../src/errors.ts' import { - allocBytes, decodePtr, decodePtrAt, errorText, getTempPath, + allocBytes, decodePtr, decodePtrAt, getTempPath, isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32, } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' @@ -48,18 +47,6 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, 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 = { @@ -83,6 +70,11 @@ describe('getTempPath', () => { expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('GetTempPathW') }) + + it('rejects a required length larger than the fixed buffer', () => { + const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings + expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) + }) }) describe('throwLastError and throwWin32', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index fe803025b9..e22a46060c 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -1,6 +1,6 @@ /** - * AclWriteGrant failure-path tests with stub binding tables (the - * failure-paths.spec.ts pattern): create fails closed on SID-parse failure, + * AclWriteGrant failure-path tests with minimal stub binding tables: create + * fails closed on SID-parse failure, * dispose aggregates revocation and SID-free failures into an * AggregateError. Pure stubs — no real Win32 calls, so these run on every * platform; the real-FFI round-trip lives in grant.spec.ts (win32 only). diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index c171d30084..adc1aaa8a1 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -11,12 +11,13 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' +import { ERROR_BROKEN_PIPE } from '@deepseek-ai/dsh-win32-process/src/abi.ts' +import { PROCESS_INFORMATION } from '@deepseek-ai/dsh-win32-process/src/ffi.ts' 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' @@ -149,7 +150,7 @@ function happyStubs(): HappyStubs { 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 getLastError = vi.fn(() => ERROR_BROKEN_PIPE) // the drains' clean EOF const formatMessageW = vi.fn(() => 0) const api = { @@ -394,6 +395,65 @@ describe('AclSandbox spawn', () => { jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) }) + + it('inherit spawn caches one failing settlement and closes the Job once', async () => { + const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs + api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + const jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr + await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) + await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) + expect(closeHandle.mock.calls.filter(([handle]) => handle === jobHandle)).toHaveLength(1) + }) + + it('inherit spawn aggregates wait and Job-close failures', async () => { + const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs + api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-1', 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({ + errors: [ + expect.objectContaining({ api: 'WaitForSingleObject' }), + expect.objectContaining({ api: 'CloseHandle' }), + ], + }) + }) + + it('pipe spawn reports a wait failure after successful drains', async () => { + const { api } = state.stubs as HappyStubs + api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-2', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) + }) + + it('pipe spawn still closes the process after a drain failure', async () => { + const { api } = state.stubs as HappyStubs + api.getLastError = vi.fn(() => 5) + const waitForSingleObject = vi.fn(() => 0) + api.waitForSingleObject = waitForSingleObject + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toMatchObject({ + errors: [ + expect.objectContaining({ api: 'PeekNamedPipe' }), + expect.objectContaining({ api: 'PeekNamedPipe' }), + ], + }) + expect(waitForSingleObject).toHaveBeenCalledOnce() + }) }) describe('AclSandbox dispose', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts deleted file mode 100644 index 5af00fb9cb..0000000000 --- a/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * quoteArg unit tests plus a round-trip through the REAL CommandLineToArgvW - * parser (shell32.dll, shellapi.h line ~867: - * `LPWSTR *CommandLineToArgvW(LPCWSTR lpCmdLine, int *pNumArgs)`) on win32. - * - * CommandLineToArgvW applies the documented backslash rule (2n backslashes - * before a quote produce n backslashes and toggle quoting; 2n+1 produce n - * backslashes and a literal quote) to every token EXCEPT the first — the - * first token is parsed with backslashes literal and quotes toggling - * (verified empirically on this machine, Windows 11 build 26200). The - * round-trip therefore prepends a plain program token, exactly like - * buildCommandLine's real callers do, so the arguments under test land on - * the rule-applying tokens. - * - * Reading argv from CommandLineToArgvW: koffi cannot decode the returned - * LPWSTR* contents directly (the pointed-to strings are not koffi-registered - * references), so each string is copied with lstrcpynW (winbase.h line - * ~1500) into a Node Buffer and read as UTF-16LE; lengths come from - * lstrlenW (winbase.h line ~1506); the argv block is freed with LocalFree - * (winbase.h line ~1127) — CommandLineToArgvW's documented contract. - */ - -import { describe, expect, it } from 'vitest' - -import { buildCommandLine, quoteArg } from '../src/spawn.ts' - -const isWin32 = process.platform === 'win32' - -/** - * Table cases: input argv entry → the exact command-line fragment quoteArg - * must produce. Trailing-backslash inputs are the regression: the closing - * quote must be preceded by DOUBLED backslashes, or the parser reads them as - * escaping the closing quote. - */ -const cases: Array<[input: string, quoted: string]> = [ - ['', '""'], - ['a', 'a'], - ['a b', '"a b"'], - ['a"b', '"a\\"b"'], - ['a\\b', 'a\\b'], - ['a b\\', '"a b\\\\"'], - ['a b\\\\', '"a b\\\\\\\\"'], - ['a b\\\\\\', '"a b\\\\\\\\\\\\"'], - ['a\\\\"b', '"a\\\\\\\\\\"b"'], -] - -describe('quoteArg', () => { - it.each(cases)('quotes %j as %j', (input, quoted) => { - expect(quoteArg(input)).toBe(quoted) - }) -}) - -describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => { - it('parses quoteArg+join back to the exact original argv', async () => { - const { default: koffi } = await import('koffi') - const PVOID = koffi.pointer('void') - const shell32 = koffi.load('shell32.dll') - const kernel32 = koffi.load('kernel32.dll') - const commandLineToArgvW = shell32.func('__stdcall', 'CommandLineToArgvW', PVOID, ['str16', koffi.pointer('int')]) - const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int']) - const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID]) - const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID]) - - const parse = (commandLine: string): string[] => { - const countSlot = koffi.alloc('int', 1) as unknown - const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown - try { - if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL') - const count = koffi.decode(countSlot, 0, 'int') as number - const table = Buffer.from(koffi.view(argvBlock, count * 8)) - const parsed: string[] = [] - for (let index = 0; index < count; index++) { - const stringAddress = table.readBigUInt64LE(index * 8) - const copied = Buffer.alloc(2048) - lstrcpynW(copied, stringAddress, copied.length / 2) - const length = lstrlenW(copied) as number - parsed.push(copied.subarray(0, length * 2).toString('utf16le')) - } - return parsed - } finally { - localFree(argvBlock) - } - } - - const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a b\\\\\\', 'a\\\\"b'] - expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv]) - }) -}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index b3559ee781..6149d75277 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -1,6 +1,6 @@ /** - * Restricted-token failure-path tests with stub binding tables (the - * failure-paths.spec.ts pattern): every checked Win32 call in the token + * Restricted-token failure-path tests with minimal stub binding tables: 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 @@ -8,12 +8,12 @@ * lives in acl.spec.ts (win32 only). */ +import { Win32Error } from '@deepseek-ai/dsh-win32-process' 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' diff --git a/packages/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json index 1d7a70a5b1..97f5530dd8 100644 --- a/packages/sandbox/sandbox-windows-acl/tsconfig.json +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../subprocess/win32-process" } ] } diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index a74afe9d80..55a3d9c9d8 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -1,6 +1,3 @@ -// ABI probe: prints sizeof/offsetof/enum values from the actual MinGW Windows -// headers on this machine. These numbers are the source of truth for the -// koffi FFI definitions in the Node.js port. #include #include #include @@ -11,185 +8,67 @@ int wmain() { - P(sizeof(void*)); - P(sizeof(HANDLE)); - P(sizeof(DWORD)); - P(sizeof(WORD)); - P(sizeof(BOOL)); + P(sizeof(TRUSTEE_W)); + P(offsetof(TRUSTEE_W, ptstrName)); + P(sizeof(EXPLICIT_ACCESS_W)); + P(offsetof(EXPLICIT_ACCESS_W, Trustee)); + P(sizeof(SID_AND_ATTRIBUTES)); + P(offsetof(SID_AND_ATTRIBUTES, Attributes)); + P(sizeof(TOKEN_GROUPS)); + P(offsetof(TOKEN_GROUPS, Groups)); + P(SECURITY_MAX_SID_SIZE); + P(SID_MAX_SUB_AUTHORITIES); + P(TOKEN_ASSIGN_PRIMARY); + P(TOKEN_DUPLICATE); + P(TOKEN_QUERY); + P(TOKEN_ADJUST_DEFAULT); + P(SE_GROUP_LOGON_ID); + P(FILE_GENERIC_WRITE); + P(STANDARD_RIGHTS_WRITE); + P(DELETE); + P(FILE_DELETE_CHILD); + P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE)); + P(FILE_SHARE_READ); + P(FILE_SHARE_WRITE); + P(FILE_SHARE_DELETE); + P(GENERIC_READ); + P(GENERIC_WRITE); + P(OPEN_ALWAYS); + P(LOCKFILE_EXCLUSIVE_LOCK); + P(LOCKFILE_FAIL_IMMEDIATELY); + P(ERROR_LOCK_VIOLATION); + P(INHERITED_ACE); + P(DISABLE_MAX_PRIVILEGE); + P(LUA_TOKEN); + P(WRITE_RESTRICTED); + P((int)WinWorldSid); + P((int)TokenGroups); + P((int)SE_FILE_OBJECT); + P(DACL_SECURITY_INFORMATION); + P((int)TRUSTEE_IS_UNKNOWN); + P((int)TRUSTEE_IS_SID); + P((int)GRANT_ACCESS); + P((int)REVOKE_ACCESS); + P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(MAX_PATH); + P(ERROR_SUCCESS); - P(sizeof(STARTUPINFOW)); - P(offsetof(STARTUPINFOW, cb)); - P(offsetof(STARTUPINFOW, lpReserved)); - P(offsetof(STARTUPINFOW, lpDesktop)); - P(offsetof(STARTUPINFOW, lpTitle)); - P(offsetof(STARTUPINFOW, dwX)); - P(offsetof(STARTUPINFOW, dwY)); - P(offsetof(STARTUPINFOW, dwXSize)); - P(offsetof(STARTUPINFOW, dwYSize)); - P(offsetof(STARTUPINFOW, dwXCountChars)); - P(offsetof(STARTUPINFOW, dwYCountChars)); - P(offsetof(STARTUPINFOW, dwFillAttribute)); - P(offsetof(STARTUPINFOW, dwFlags)); - P(offsetof(STARTUPINFOW, wShowWindow)); - P(offsetof(STARTUPINFOW, cbReserved2)); - P(offsetof(STARTUPINFOW, lpReserved2)); - P(offsetof(STARTUPINFOW, hStdInput)); - P(offsetof(STARTUPINFOW, hStdOutput)); - P(offsetof(STARTUPINFOW, hStdError)); - - P(sizeof(PROCESS_INFORMATION)); - P(offsetof(PROCESS_INFORMATION, hProcess)); - P(offsetof(PROCESS_INFORMATION, hThread)); - P(offsetof(PROCESS_INFORMATION, dwProcessId)); - P(offsetof(PROCESS_INFORMATION, dwThreadId)); - - P(sizeof(SECURITY_ATTRIBUTES)); - P(offsetof(SECURITY_ATTRIBUTES, nLength)); - P(offsetof(SECURITY_ATTRIBUTES, lpSecurityDescriptor)); - P(offsetof(SECURITY_ATTRIBUTES, bInheritHandle)); - - P(sizeof(TRUSTEE_W)); - P(offsetof(TRUSTEE_W, pMultipleTrustee)); - P(offsetof(TRUSTEE_W, MultipleTrusteeOperation)); - P(offsetof(TRUSTEE_W, TrusteeForm)); - P(offsetof(TRUSTEE_W, TrusteeType)); - P(offsetof(TRUSTEE_W, ptstrName)); - - P(sizeof(EXPLICIT_ACCESS_W)); - P(offsetof(EXPLICIT_ACCESS_W, grfAccessPermissions)); - P(offsetof(EXPLICIT_ACCESS_W, grfAccessMode)); - P(offsetof(EXPLICIT_ACCESS_W, grfInheritance)); - P(offsetof(EXPLICIT_ACCESS_W, Trustee)); - - P(sizeof(SID_AND_ATTRIBUTES)); - P(offsetof(SID_AND_ATTRIBUTES, Sid)); - P(offsetof(SID_AND_ATTRIBUTES, Attributes)); - - P(sizeof(TOKEN_GROUPS)); - P(offsetof(TOKEN_GROUPS, GroupCount)); - P(offsetof(TOKEN_GROUPS, Groups)); - - P(sizeof(TOKEN_MANDATORY_LABEL)); - - P(sizeof(SID)); - P(SECURITY_MAX_SID_SIZE); - P(SID_MAX_SUB_AUTHORITIES); - P(SID_REVISION); - - P(TOKEN_ASSIGN_PRIMARY); - P(TOKEN_DUPLICATE); - P(TOKEN_QUERY); - P(TOKEN_ADJUST_DEFAULT); - - P(SE_GROUP_LOGON_ID); - P(SE_GROUP_INTEGRITY); - P(SE_GROUP_INTEGRITY_ENABLED); - - P(FILE_GENERIC_WRITE); - P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); - P(STANDARD_RIGHTS_WRITE); - P(DELETE); - P(FILE_DELETE_CHILD); - P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE)); - - P(FILE_SHARE_READ); - P(FILE_SHARE_WRITE); - P(FILE_SHARE_DELETE); - P(GENERIC_READ); - P(GENERIC_WRITE); - P(OPEN_ALWAYS); - P(LOCKFILE_EXCLUSIVE_LOCK); - P(LOCKFILE_FAIL_IMMEDIATELY); - P(ERROR_LOCK_VIOLATION); - P(INHERITED_ACE); - - P(DISABLE_MAX_PRIVILEGE); - P(SANDBOX_INERT); - P(LUA_TOKEN); - P(WRITE_RESTRICTED); - - P((int)WinWorldSid); - P((int)WinLocalLogonSid); - P((int)WinConsoleLogonSid); - - P((int)TokenUser); - P((int)TokenGroups); - P((int)TokenIntegrityLevel); - - P((int)SE_FILE_OBJECT); - P(DACL_SECURITY_INFORMATION); - - P((int)TRUSTEE_IS_UNKNOWN); - P((int)TRUSTEE_IS_SID); - P((int)NOT_USED_ACCESS); - P((int)GRANT_ACCESS); - P((int)REVOKE_ACCESS); - P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); - P(OBJECT_INHERIT_ACE); - P(CONTAINER_INHERIT_ACE); - - P(CREATE_SUSPENDED); - P(CREATE_NO_WINDOW); - P(DETACHED_PROCESS); - P(CREATE_NEW_CONSOLE); - P(STARTF_USESTDHANDLES); - P(HANDLE_FLAG_INHERIT); - P(INFINITE); - - P(LMEM_FIXED); - P(LMEM_ZEROINIT); - P(LPTR); - - P(FORMAT_MESSAGE_ALLOCATE_BUFFER); - P(FORMAT_MESSAGE_FROM_SYSTEM); - P(FORMAT_MESSAGE_IGNORE_INSERTS); - P(MAX_PATH); - - P(ERROR_SUCCESS); - P(ERROR_INSUFFICIENT_BUFFER); - P(ERROR_NO_MORE_ITEMS); - P(ERROR_INVALID_PARAMETER); - P(ERROR_INVALID_SID); - P(ERROR_NONE_MAPPED); - P(ERROR_BROKEN_PIPE); - - // Job object (runner kill-on-close hardening) - P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); - P(sizeof(JOBOBJECT_BASIC_LIMIT_INFORMATION)); - P(sizeof(IO_COUNTERS)); - P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation)); - P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); - P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, ProcessMemoryLimit)); - P((int)JobObjectExtendedLimitInformation); - P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); - - // static assertions for the values the koffi module will hardcode - static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); - static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(sizeof(SECURITY_ATTRIBUTES) == 24, "SECURITY_ATTRIBUTES size"); - static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); - static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); - static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); - static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); - static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); - static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); - static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); - static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "poc grant mask"); - static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights"); - static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask"); - static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes"); - static_assert(OPEN_ALWAYS == 4, "open always"); - static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags"); - static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation"); - static_assert(INHERITED_ACE == 0x10, "inherited ace flag"); - static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); - static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); - static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); - static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); - static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); - static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); - static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); - static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); - printf("\nstatic_asserts passed\n"); - return 0; + static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); + static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); + static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); + static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); + static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); + static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); + static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights"); + static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask"); + static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes"); + static_assert(OPEN_ALWAYS == 4, "open always"); + static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags"); + static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation"); + static_assert(INHERITED_ACE == 0x10, "inherited ace flag"); + static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); + static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); + printf("\nstatic_asserts passed\n"); + return 0; } diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index b294c97415..7cdeda55c3 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/README.md -README.md: 72a30775f45a8140935a013c63e9427e9cbd94d6 -README.zh.md: 35c801a1f4a5e8ad161b8c4ff57ad6ffa07e699d +README.md: ba74f0d2ed2251c3527259b571663abf5bf740a2 +README.zh.md: fefc13d49b94ddba2e697d3481b4b991531540a8 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 72a30775f4..ba74f0d2ed 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,6 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, Job assignment, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 35c801a1f4..fefc13d49b 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,6 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、Job 指派、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml new file mode 100644 index 0000000000..6e68e09233 --- /dev/null +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md +README.md: a18b1b8167e3ea76d61f022f4aa3ea827546d93f +README.zh.md: 262300f5da48aeed4fe7c05d970d498147921c49 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md new file mode 100644 index 0000000000..a18b1b8167 --- /dev/null +++ b/packages/subprocess/win32-process/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-win32-process + +English | [中文](README.zh.md) + +Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the repository's one Koffi binding table for reusable restricted-process, stdio, and Job Object operations; it is not a Cordis service and does not choose sandbox policy or public child behavior. + +## Behavior + +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. +- **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, restores the parent handle flags, and resumes the child. Creation, assignment, or resume failure closes every owned resource; assignment failure terminates the still-suspended child before releasing its process and thread handles. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. + +The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. + +## Model Experience + +### Process primitives + +#### What the model sees + +Nothing directly. The package exposes `Win32ProcessBindings` and process primitives to the sandbox, which owns all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema. + +#### Token effect + +None directly. Consumers decide whether process output enters a tool result or later model request. + +#### KV Cache effect + +The package contributes no stable request prefix, so it does not invalidate model KV caches. + +## Known Limitations and Deferred Work + +- **Windows-only native loading** — importing the generic types is portable, but resolving the binding table loads Windows DLLs and fails on other hosts. Cross-platform tests inject a binding table instead of loading native APIs. +- **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. +- **Inherited environment only** — process creation passes a null environment block. Callers that need environment changes must establish them before invoking the primitive or use their own runner process. +- **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. +- **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md new file mode 100644 index 0000000000..262300f5da --- /dev/null +++ b/packages/subprocess/win32-process/README.zh.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-win32-process + +[English](README.md) | 中文 + +供 Windows ACL 沙箱消费的底层 Win32 进程库。它唯一拥有仓库中可复用 restricted-process、stdio 与 Job Object 操作的 Koffi 绑定表;它不是 Cordis 服务,也不决定沙箱策略或公共 child 行为。 + +## Behavior + +- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 +- **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,将其指派给 Job,恢复父进程句柄标志,再 resume child。创建、指派或 resume 失败都会关闭全部已拥有资源;指派失败会先终止仍 suspended 的 child,再释放其 process 与 thread handles。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 + +Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 + +## Model Experience + +### 进程原语 + +#### 模型看到什么 + +没有直接内容。本包向 sandbox 提供 `Win32ProcessBindings` 与进程原语;sandbox 拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。 + +#### Token 影响 + +没有直接影响。消费方决定进程输出是否进入工具结果或后续模型请求。 + +#### KV Cache effect + +本包不贡献稳定请求前缀,因此不会使模型 KV Cache 失效。 + +## Known Limitations and Deferred Work + +- **仅在 Windows 原生加载** — 导入通用类型可跨平台进行,但解析绑定表会加载 Windows DLL,并在其他宿主失败。跨平台测试注入绑定表,不加载原生 API。 +- **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 +- **只继承环境** — 进程创建传入空环境块。需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 +- **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 +- **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json new file mode 100644 index 0000000000..7d6257d692 --- /dev/null +++ b/packages/subprocess/win32-process/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-win32-process", + "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subprocess/win32-process" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "koffi": "^3.1.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts new file mode 100644 index 0000000000..fbdda9059f --- /dev/null +++ b/packages/subprocess/win32-process/src/abi.ts @@ -0,0 +1,38 @@ +/** Generic Win32 process, stdio, and Job Object constants verified on x64. */ + +/** STARTUPINFOW uses the standard input, output, and error handles. */ +export const STARTF_USESTDHANDLES = 0x00000100 +/** HandleInformation flag that permits child inheritance. */ +export const HANDLE_FLAG_INHERIT = 0x1 +/** Infinite WaitForSingleObject timeout. */ +export const INFINITE = 0xFFFFFFFF +/** CreateProcess flag that prevents user code from running before resume. */ +export const CREATE_SUSPENDED = 0x4 +/** GetStdHandle selector for standard input. */ +export const STD_INPUT_HANDLE = -10 +/** GetStdHandle selector for standard output. */ +export const STD_OUTPUT_HANDLE = -11 +/** GetStdHandle selector for standard error. */ +export const STD_ERROR_HANDLE = -12 +/** FormatMessage reads the operating system message table. */ +export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +/** FormatMessage leaves insertion placeholders uninterpreted. */ +export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 +/** Win32 code reporting a caller-provided buffer is too small. */ +export const ERROR_INSUFFICIENT_BUFFER = 122 +/** Win32 code reporting that the other pipe end closed. */ +export const ERROR_BROKEN_PIPE = 109 +/** Win32 code reporting that a pipe has no remaining data. */ +export const ERROR_NO_DATA = 232 +/** Job limit that terminates every member when the final Job handle closes. */ +export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +/** SetInformationJobObject class for JOBOBJECT_EXTENDED_LIMIT_INFORMATION. */ +export const JobObjectExtendedLimitInformation = 9 +/** x64 JOBOBJECT_EXTENDED_LIMIT_INFORMATION byte size. */ +export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 +/** Byte offset of BasicLimitInformation.LimitFlags in the extended Job record. */ +export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 +/** x64 STARTUPINFOW byte size verified by the native probe. */ +export const STARTUPINFOW_SIZE = 104 +/** x64 PROCESS_INFORMATION byte size verified by the native probe. */ +export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts new file mode 100644 index 0000000000..84bd2a7ac2 --- /dev/null +++ b/packages/subprocess/win32-process/src/errors.ts @@ -0,0 +1,14 @@ +/** Win32 call failure with the exact API name and error code. */ +export class Win32Error extends Error { + /** Win32 function whose checked result failed. */ + readonly api: string + /** Exact GetLastError value captured before cleanup changed it. */ + readonly win32Code: number + + constructor(api: string, win32Code: number, detail?: string) { + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + this.name = 'Win32Error' + this.api = api + this.win32Code = win32Code + } +} diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts new file mode 100644 index 0000000000..38b3e32200 --- /dev/null +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -0,0 +1,319 @@ +/** Lazy Koffi bindings for generic Win32 process, stdio, and Job operations. */ + +import koffi from 'koffi' +import * as abi from './abi.ts' +import { Win32Error } from './errors.ts' + +declare const nativePtr: unique symbol +/** Koffi native pointer branded against accidental numeric use. */ +export type NativePtr = bigint & { readonly [nativePtr]: true } + +type Ptr = ReturnType +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +/** Loaded Win32 libraries and the shared stdcall binder used by process extensions. */ +export interface Win32BindingContext { + /** Kernel process, handle, pipe, and Job APIs. */ + readonly kernel32: ReturnType + /** Token and security APIs. */ + readonly advapi32: ReturnType + /** Bind one stdcall function from a loaded Win32 library. */ + readonly bind: ( + library: ReturnType, + name: string, + result: Ptr | string, + args: Array, + ) => unknown +} + +/** + * Return whether a Koffi pointer represents NULL. + * @param value - pointer value returned by Koffi or a Win32 call. + * @returns true for null, undefined, or address zero. + */ +export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { + return value === null || value === undefined || (value as bigint) === 0n +} + +/** STARTUPINFOW fields used by inherited or piped stdio launches. */ +export interface StartupInfoInput { + cb: number + dwFlags: number + hStdInput: NativePtr + hStdOutput: NativePtr + hStdError: NativePtr +} + +/** Decoded PROCESS_INFORMATION result. */ +export interface ProcessInfoOutput { + hProcess: NativePtr | null + hThread: NativePtr | null + dwProcessId: number + dwThreadId: number +} + +/** Generic Win32 calls consumed by restricted-token sandbox process operations. */ +export interface Win32ProcessBindings { + closeHandle(handle: NativePtr): number + getLastError(): number + formatMessageW( + flags: number, + source: null, + messageId: number, + languageId: number, + buffer: Buffer, + size: number, + args: null, + ): number + createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + setHandleInformation(handle: NativePtr, mask: number, flags: number): number + createProcessAsUserW( + token: NativePtr, + applicationName: string | null, + commandLine: string, + processAttributes: null, + threadAttributes: null, + inheritHandles: number, + creationFlags: number, + environment: null, + currentDirectory: string | null, + startupInfo: NativePtr, + processInfo: NativePtr, + ): number + readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number + peekNamedPipe( + pipe: NativePtr, + buffer: null, + size: number, + bytesRead: NativePtr | null, + totalAvail: NativePtr, + leftThisMessage: NativePtr | null, + ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number + getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number + resumeThread(thread: NativePtr): number + createJobObjectW(attributes: null, name: null): NativePtr + setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + terminateProcess(process: NativePtr, exitCode: number): number + getStdHandle(stdHandle: number): NativePtr +} + +/** Koffi STARTUPINFOW layout. */ +export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', { + cb: 'uint32', + lpReserved: 'str16', + lpDesktop: 'str16', + lpTitle: 'str16', + dwX: 'uint32', + dwY: 'uint32', + dwXSize: 'uint32', + dwYSize: 'uint32', + dwXCountChars: 'uint32', + dwYCountChars: 'uint32', + dwFillAttribute: 'uint32', + dwFlags: 'uint32', + wShowWindow: 'uint16', + cbReserved2: 'uint16', + lpReserved2: koffi.pointer('uint8'), + hStdInput: PVOID, + hStdOutput: PVOID, + hStdError: PVOID, +}) + +/** Koffi PROCESS_INFORMATION layout. */ +export const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', { + hProcess: PVOID, + hThread: PVOID, + dwProcessId: 'uint32', + dwThreadId: 'uint32', +}) + +/* v8 ignore start -- ABI guards are pinned by native header probes. */ +if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { + throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, expected ${abi.STARTUPINFOW_SIZE}`) +} +if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { + throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, expected ${abi.PROCESS_INFORMATION_SIZE}`) +} +/* v8 ignore stop */ + +/** + * Allocate a pointer-sized out-parameter slot. + * @returns allocated native slot. + */ +export function allocPtrSlot(): NativePtr { + return koffi.alloc(PVOID, 1) as NativePtr +} + +/** + * Allocate a uint32 out-parameter slot. + * @returns allocated native slot. + */ +export function allocUint32(): NativePtr { + return koffi.alloc('uint32', 1) as NativePtr +} + +/** + * Decode a pointer out-parameter. + * @param slot - pointer-sized slot filled by Win32. + * @returns decoded pointer, or null for address zero. + */ +export function decodePtr(slot: NativePtr): NativePtr | null { + const value = koffi.decode(slot, PVOID) as NativePtr | null + return isNullPtr(value) ? null : value +} + +/** + * Decode a uint32 out-parameter. + * @param slot - uint32 slot filled by Win32. + * @returns decoded unsigned value. + */ +export function decodeUint32(slot: NativePtr): number { + return koffi.decode(slot, 'uint32') as number +} + +/** + * Allocate a zeroed STARTUPINFOW. + * @returns allocated struct pointer. + */ +export function allocStartupInfo(): NativePtr { + return koffi.alloc(STARTUPINFOW, 1) as NativePtr +} + +/** + * Encode the stdio-bearing STARTUPINFOW fields. + * @param startupInfo - allocated STARTUPINFOW pointer. + * @param fields - fields required for inherited stdio. + */ +export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { + koffi.encode(startupInfo, STARTUPINFOW, fields) +} + +/** + * Allocate a zeroed PROCESS_INFORMATION. + * @returns allocated struct pointer. + */ +export function allocProcessInfo(): NativePtr { + return koffi.alloc(PROCESS_INFORMATION, 1) as NativePtr +} + +/** + * Decode PROCESS_INFORMATION. + * @param processInfo - struct pointer filled by CreateProcess. + * @returns process/thread handles and ids. + */ +export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { + return koffi.decode(processInfo, PROCESS_INFORMATION) as ProcessInfoOutput +} + +let cachedContext: Win32BindingContext | undefined +let cached: Win32ProcessBindings | undefined + +/* v8 ignore start -- exercised by native Windows ABI and sandbox jobs. */ +function bindingContext(): Win32BindingContext { + if (cachedContext !== undefined) return cachedContext + const kernel32 = koffi.load('kernel32.dll') + const advapi32 = koffi.load('advapi32.dll') + const bind = ( + lib: ReturnType, + name: string, + result: Ptr | string, + args: Array, + ): unknown => lib.func('__stdcall', name, result, args) + cachedContext = { kernel32, advapi32, bind } + return cachedContext +} + +function bindings(): Win32ProcessBindings { + if (cached !== undefined) return cached + const { kernel32, advapi32, bind } = bindingContext() + cached = { + closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), + getLastError: bind(kernel32, 'GetLastError', 'uint32', []), + formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', [ + 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, + ]), + createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), + createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ + PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', + koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + ]), + readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), + peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ + PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), + ]), + waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), + getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), + createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), + setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), + getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), + } as unknown as Win32ProcessBindings + return cached +} + +/** + * Extend the shared process table with caller-owned Win32 API families. + * @param create - binds only the caller-specific operations from the shared libraries. + * @returns generic process bindings combined with the caller-specific operations. + */ +export function extendWin32ProcessBindings( + create: (context: Win32BindingContext) => Extension, +): Win32ProcessBindings & Extension { + return { ...bindings(), ...create(bindingContext()) } +} +/* v8 ignore stop */ + +/** + * Format a Win32 error code through FormatMessageW. + * @param api - active binding table. + * @param win32Code - captured GetLastError value. + * @returns trimmed system message, or an empty string when unavailable. + */ +export function errorText(api: Win32ProcessBindings, win32Code: number): string { + const buffer = Buffer.alloc(1024) + const length = api.formatMessageW( + abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, + null, + win32Code, + 0, + buffer, + buffer.length / 2, + null, + ) + return length === 0 ? '' : buffer.subarray(0, length * 2).toString('utf16le').trim() +} + +/** + * Throw the current GetLastError value. + * @param api - active binding table. + * @param name - failing Win32 operation. + * @param detail - optional operation context. + * @returns never; always throws Win32Error. + */ +export function throwLastError(api: Win32ProcessBindings, name: string, detail?: string): never { + const win32Code = api.getLastError() + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} + +/** + * Throw an explicitly captured Win32 error code. + * @param api - active binding table. + * @param name - failing Win32 operation. + * @param win32Code - error captured before cleanup. + * @param detail - optional operation context. + * @returns never; always throws Win32Error. + */ +export function throwWin32( + api: Win32ProcessBindings, + name: string, + win32Code: number, + detail?: string, +): never { + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts new file mode 100644 index 0000000000..fa7f2dd992 --- /dev/null +++ b/packages/subprocess/win32-process/src/index.ts @@ -0,0 +1,29 @@ +/** Low-level Win32 process, stdio, and Job Object primitives used by the Windows ACL sandbox. */ + +export { ERROR_INSUFFICIENT_BUFFER } from './abi.ts' +export * from './errors.ts' +export { + allocPtrSlot, + allocUint32, + decodePtr, + decodeUint32, + extendWin32ProcessBindings, + isNullPtr, + throwLastError, + throwWin32, +} from './ffi.ts' +export type { + NativePtr, + Win32ProcessBindings, +} from './ffi.ts' +export { + closeHandleChecked, + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from './process.ts' +export type { + SpawnedJobProcess, + SpawnedPipedProcess, +} from './process.ts' diff --git a/packages/subprocess/win32-process/src/invariant.ts b/packages/subprocess/win32-process/src/invariant.ts new file mode 100644 index 0000000000..bd29124923 --- /dev/null +++ b/packages/subprocess/win32-process/src/invariant.ts @@ -0,0 +1,17 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-win32-process`. */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-win32-process' + +export const name = 'win32-process-invariant' +export const inject = ['invariants'] + +/** No runtime invariant: operations own only call-local native handles. */ +const install: InvariantInstaller = () => {} + +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts new file mode 100644 index 0000000000..f62195413f --- /dev/null +++ b/packages/subprocess/win32-process/src/process.ts @@ -0,0 +1,431 @@ +/** Typed Win32 process operations over the shared binding table. */ + +import koffi from 'koffi' +import * as abi from './abi.ts' +import { + allocProcessInfo, + allocPtrSlot, + allocStartupInfo, + allocUint32, + decodeProcessInfo, + decodePtr, + decodeUint32, + encodeStartupInfo, + isNullPtr, + throwLastError, + throwWin32, +} from './ffi.ts' +import type { NativePtr, Win32ProcessBindings } from './ffi.ts' + +/** + * Quote one argument according to CommandLineToArgvW parsing. + * @param argument - one argv entry. + * @returns bare or quoted command-line segment. + */ +export function quoteArg(argument: string): string { + if (argument === '') return '""' + if (!/[\s"]/u.test(argument)) return argument + let quoted = '"' + for (let index = 0; index < argument.length; index++) { + let backslashes = 0 + while (index < argument.length && argument.charAt(index) === '\\') { + backslashes += 1 + index += 1 + } + if (index === argument.length) { + quoted += '\\'.repeat(backslashes * 2) + } else if (argument.charAt(index) === '"') { + quoted += '\\'.repeat(backslashes * 2 + 1) + '"' + } else { + quoted += '\\'.repeat(backslashes) + argument.charAt(index) + } + } + return quoted + '"' +} + +/** + * Build the mutable command line accepted by CreateProcessAsUserW. + * @param program - executable argv entry. + * @param args - remaining argv entries. + * @returns joined Win32 command line. + */ +export function buildCommandLine(program: string, args: readonly string[]): string { + return [program, ...args].map(quoteArg).join(' ') +} + +/** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ +export interface RestrictedProcessSpawnOptions { + /** Executable argv entry passed through CreateProcessAsUserW. */ + command: string + /** Arguments excluding the executable. */ + args: readonly string[] + /** Existing child working directory. */ + cwd: string + /** Restricted primary token supplied by sandbox policy. */ + token: NativePtr +} + +/** Piped child resources whose process and read handles remain caller-owned. */ +export interface SpawnedPipedProcess { + /** Direct child process id. */ + pid: number + /** Process handle closed by waitForProcessExit. */ + process: NativePtr + /** Stdout pipe read end closed by drainPipe. */ + stdoutRead: NativePtr + /** Stderr pipe read end closed by drainPipe. */ + stderrRead: NativePtr +} + +/** Suspended-created child assigned to one caller-owned kill-on-close Job before resume. */ +export interface SpawnedJobProcess { + /** Direct child process id. */ + pid: number + /** Process handle closed by waitForProcessExit. */ + process: NativePtr + /** Job handle closed by the lifecycle owner. */ + job: NativePtr +} + +interface PipePair { + read: NativePtr + write: NativePtr +} + +function freeNative(pointer: NativePtr | undefined): void { + if (pointer !== undefined) koffi.free(pointer) +} + +function closeBestEffort(api: Win32ProcessBindings, handle: NativePtr | null | undefined): void { + if (!isNullPtr(handle)) api.closeHandle(handle) +} + +function createPipe(api: Win32ProcessBindings, owned: Set): PipePair { + const readSlot = allocPtrSlot() + let writeSlot: NativePtr | undefined + try { + writeSlot = allocPtrSlot() + if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') + const read = decodePtr(readSlot) + const write = decodePtr(writeSlot) + if (read === null || write === null) { + closeBestEffort(api, read) + closeBestEffort(api, write) + throwLastError(api, 'CreatePipe', 'null pipe handle') + } + owned.add(read) + owned.add(write) + return { read, write } + } finally { + freeNative(writeSlot) + koffi.free(readSlot) + } +} + +function closeOwned(api: Win32ProcessBindings, owned: Set, handle: NativePtr): void { + /* v8 ignore next -- each successfully decoded pipe end is uniquely owned. */ + if (!owned.delete(handle)) return + api.closeHandle(handle) +} + +function closeAllOwned(api: Win32ProcessBindings, owned: Set): void { + for (const handle of owned) api.closeHandle(handle) + owned.clear() +} + +function createRestrictedProcess( + api: Win32ProcessBindings, + options: RestrictedProcessSpawnOptions, + commandLine: string, + creationFlags: number, + startupInfo: NativePtr, + processInfo: NativePtr, +): number { + return api.createProcessAsUserW( + options.token, + null, + commandLine, + null, + null, + 1, + creationFlags, + null, + options.cwd, + startupInfo, + processInfo, + ) +} + +/** + * Spawn a process with anonymous-pipe stdout/stderr and immediate stdin EOF. + * @param api - active binding table. + * @param options - command, cwd, args, and restricted primary token. + * @returns caller-owned process and pipe read handles. + */ +export function spawnPipedProcess( + api: Win32ProcessBindings, + options: RestrictedProcessSpawnOptions, +): SpawnedPipedProcess { + const owned = new Set() + let startupInfo: NativePtr | undefined + let processInfo: NativePtr | undefined + try { + const stdIn = createPipe(api, owned) + const stdOut = createPipe(api, owned) + const stdErr = createPipe(api, owned) + for (const [handle, label] of [ + [stdIn.read, 'stdin read end'], + [stdOut.write, 'stdout write end'], + [stdErr.write, 'stderr write end'], + ] as const) { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', label) + } + } + startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn.read, + hStdOutput: stdOut.write, + hStdError: stdErr.write, + }) + processInfo = allocProcessInfo() + const created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + 0, + startupInfo, + processInfo, + ) + if (created === 0) { + const win32Code = api.getLastError() + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } + const info = decodeProcessInfo(processInfo) + if (info.hProcess === null || info.hThread === null) { + if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + closeOwned(api, owned, stdIn.read) + closeOwned(api, owned, stdIn.write) + closeOwned(api, owned, stdOut.write) + closeOwned(api, owned, stdErr.write) + closeBestEffort(api, info.hThread) + owned.delete(stdOut.read) + owned.delete(stdErr.read) + return { + pid: info.dwProcessId, + process: info.hProcess, + stdoutRead: stdOut.read, + stderrRead: stdErr.read, + } + } catch (error) { + closeAllOwned(api, owned) + throw error + } finally { + freeNative(processInfo) + freeNative(startupInfo) + } +} + +/** + * Drain one anonymous pipe until the writer closes it. + * @param api - active binding table. + * @param handle - caller-owned pipe read end. + * @returns complete bytes read before EOF; the handle is always closed. + */ +export async function drainPipe(api: Win32ProcessBindings, handle: NativePtr): Promise { + const chunks: Buffer[] = [] + let countSlot: NativePtr | undefined + try { + countSlot = allocUint32() + for (;;) { + const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) + if (peeked === 0) { + const win32Code = api.getLastError() + if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break + throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) + } + const available = decodeUint32(countSlot) + if (available > 0) { + const chunk = Buffer.alloc(available) + if (api.readFile(handle, chunk, chunk.length, countSlot, null) === 0) { + throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) + } + chunks.push(chunk.subarray(0, decodeUint32(countSlot))) + } + await new Promise(resolve => setTimeout(resolve, 1)) + } + return Buffer.concat(chunks) + } finally { + freeNative(countSlot) + api.closeHandle(handle) + } +} + +/** + * Wait for a process and always close its handle. + * @param api - active binding table. + * @param process - caller-owned process handle. + * @returns direct process exit code. + */ +export function waitForProcessExit(api: Win32ProcessBindings, process: NativePtr): number { + let exitCodeSlot: NativePtr | undefined + try { + if (api.waitForSingleObject(process, abi.INFINITE) === 0xFFFFFFFF) { + throwLastError(api, 'WaitForSingleObject') + } + exitCodeSlot = allocUint32() + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + return decodeUint32(exitCodeSlot) + } finally { + freeNative(exitCodeSlot) + api.closeHandle(process) + } +} + +function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { + const job = api.createJobObjectW(null, null) + if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') + const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) + information.writeUInt32LE( + abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET, + ) + if (api.setInformationJobObject( + job, + abi.JobObjectExtendedLimitInformation, + information, + information.length, + ) === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'SetInformationJobObject', win32Code) + } + return job +} + +/** + * Spawn suspended inside a kill-on-close Job, then resume. + * @param api - active binding table. + * @param options - command, cwd, args, and restricted primary token. + * @returns caller-owned process and Job handles after successful resume. + */ +export function spawnInheritedJobProcess( + api: Win32ProcessBindings, + options: RestrictedProcessSpawnOptions, +): SpawnedJobProcess { + const job = createKillOnCloseJob(api) + const getStdHandle = (selector: number, label: string): NativePtr => { + const handle = api.getStdHandle(selector) + if (!isNullPtr(handle)) return handle + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) + } + const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') + const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') + const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') + const enabled: NativePtr[] = [] + let startupInfo: NativePtr | undefined + let processInfo: NativePtr | undefined + let created = 0 + let createFailureCode = 0 + try { + for (const [handle, label] of [ + [stdIn, 'stdin'], + [stdOut, 'stdout'], + [stdErr, 'stderr'], + ] as const) { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) + } + enabled.push(handle) + } + startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn, + hStdOutput: stdOut, + hStdError: stdErr, + }) + processInfo = allocProcessInfo() + created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + ) + if (created === 0) createFailureCode = api.getLastError() + } catch (error) { + freeNative(processInfo) + freeNative(startupInfo) + api.closeHandle(job) + throw error + } finally { + for (const handle of enabled) api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } + if (created === 0) { + freeNative(processInfo) + freeNative(startupInfo) + api.closeHandle(job) + throwWin32( + api, + 'CreateProcessAsUserW', + createFailureCode, + `command: ${options.command}, cwd: ${options.cwd}`, + ) + } + let info: ReturnType + try { + info = decodeProcessInfo(processInfo) + } finally { + freeNative(processInfo) + freeNative(startupInfo) + } + if (info.hProcess === null || info.hThread === null) { + if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + if (api.assignProcessToJobObject(job, info.hProcess) === 0) { + const win32Code = api.getLastError() + api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { + const win32Code = api.getLastError() + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } + closeBestEffort(api, info.hThread) + return { pid: info.dwProcessId, process: info.hProcess, job } +} + +/** + * Close a handle and surface a failure without losing its operation label. + * @param api - active binding table. + * @param handle - caller-owned handle to close. + * @param detail - lifecycle label included in a failure. + */ +export function closeHandleChecked( + api: Win32ProcessBindings, + handle: NativePtr, + detail: string, +): void { + if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', detail) +} diff --git a/packages/subprocess/win32-process/tests/ffi.spec.ts b/packages/subprocess/win32-process/tests/ffi.spec.ts new file mode 100644 index 0000000000..dfe27537f3 --- /dev/null +++ b/packages/subprocess/win32-process/tests/ffi.spec.ts @@ -0,0 +1,45 @@ +import koffi from 'koffi' +import { describe, expect, it, vi } from 'vitest' +import { + Win32Error, + allocPtrSlot, + decodePtr, + isNullPtr, + throwLastError, +} from '../src/index.ts' +import { PROCESS_INFORMATION_SIZE, STARTUPINFOW_SIZE } from '../src/abi.ts' +import { PROCESS_INFORMATION, STARTUPINFOW, errorText } from '../src/ffi.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' + +describe('shared Win32 process ABI', () => { + it('matches the verified x64 structure sizes', () => { + expect(STARTUPINFOW.size).toBe(STARTUPINFOW_SIZE) + expect(PROCESS_INFORMATION.size).toBe(PROCESS_INFORMATION_SIZE) + }) + + it('handles NULL pointer out-parameters', () => { + const slot = allocPtrSlot() + expect(decodePtr(slot)).toBeNull() + expect(isNullPtr(0n as NativePtr)).toBe(true) + expect(isNullPtr(1n as NativePtr)).toBe(false) + }) + + it('formats and throws the exact Win32 error', () => { + const api = { + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn((_flags, _source, _id, _language, buffer: Buffer) => { + buffer.write('access denied', 'utf16le') + return 'access denied'.length + }), + } as unknown as Win32ProcessBindings + expect(errorText(api, 5)).toBe('access denied') + expect(() => throwLastError(api, 'Probe')).toThrow(Win32Error) + expect(new Win32Error('CloseHandle', 6).message).toBe('CloseHandle failed (Win32 6)') + }) + + it('decodes a pointer stored by Koffi', () => { + const slot = allocPtrSlot() + koffi.encode(slot, koffi.pointer('void'), 42n) + expect(decodePtr(slot)).toBe(42n) + }) +}) diff --git a/packages/subprocess/win32-process/tests/invariant.spec.ts b/packages/subprocess/win32-process/tests/invariant.spec.ts new file mode 100644 index 0000000000..82078672d3 --- /dev/null +++ b/packages/subprocess/win32-process/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest' +import { apply, inject, name } from '../src/invariant.ts' + +describe('win32-process invariant companion', () => { + it('registers the package-owned empty invariant', async () => { + const dispose = vi.fn() + const register = vi.fn((_packageName: string, _installer: () => void) => dispose) + const ctx = { invariants: { register } } as never + await expect(apply(ctx)).resolves.toBe(dispose) + expect(name).toBe('win32-process-invariant') + expect(inject).toEqual(['invariants']) + expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-win32-process', expect.any(Function)) + const installer = register.mock.calls[0]![1] + installer() + }) +}) diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts new file mode 100644 index 0000000000..714af104b2 --- /dev/null +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -0,0 +1,145 @@ +import koffi from 'koffi' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from '../src/index.ts' +import * as ffi from '../src/ffi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts' + +vi.mock('../src/ffi.ts', { spy: true }) + +const PVOID = koffi.pointer('void') + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('spawnInheritedJobProcess allocation cleanup', () => { + it('frees startup info when process-info allocation throws', () => { + const api = { + createJobObjectW: vi.fn(() => 50n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + setHandleInformation: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + vi.mocked(ffi.allocProcessInfo).mockImplementationOnce(() => { throw new Error('process-info allocation failed') }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\', + token: 70n as NativePtr, + })).toThrow('process-info allocation failed') + expect(free).toHaveBeenCalledOnce() + }) + + it('frees process info after a successful inherited spawn', () => { + const api = { + createJobObjectW: vi.fn(() => 50n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + expect(spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\', + token: 70n as NativePtr, + })).toEqual({ pid: 1234, process: 60n, job: 50n }) + expect(free).toHaveBeenCalledTimes(2) + }) +}) + +describe('shared process allocation cleanup', () => { + it('frees pipe slots and process structs after a successful piped spawn', () => { + let nextHandle = 10n + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, nextHandle++) + koffi.encode(writeSlot, PVOID, nextHandle++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }), + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + expect(spawnPipedProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\', + token: 70n as NativePtr, + })).toMatchObject({ pid: 1234, process: 60n }) + expect(free).toHaveBeenCalledTimes(8) + }) + + it('reuses one drain count slot and frees it at EOF', async () => { + let peeks = 0 + const api = { + peekNamedPipe: vi.fn((_pipe, _buffer, _size, _read, totalAvail: NativePtr) => { + peeks += 1 + if (peeks > 1) return 0 + koffi.encode(totalAvail, 'uint32', 1) + return 1 + }), + readFile: vi.fn((_file, buffer: Buffer, _count, readSlot: NativePtr) => { + buffer[0] = 0x61 + koffi.encode(readSlot, 'uint32', 1) + return 1 + }), + getLastError: vi.fn(() => 109), + closeHandle: vi.fn(() => 1), + } as unknown as Win32ProcessBindings + const alloc = vi.spyOn(koffi, 'alloc') + const free = vi.spyOn(koffi, 'free') + await expect(drainPipe(api, 70n as NativePtr)).resolves.toEqual(Buffer.from('a')) + expect(alloc).toHaveBeenCalledOnce() + expect(free).toHaveBeenCalledOnce() + }) + + it('frees the exit-code slot after reading a process result', () => { + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn((_process, exitCode: NativePtr) => { + koffi.encode(exitCode, 'uint32', 42) + return 1 + }), + closeHandle: vi.fn(() => 1), + } as unknown as Win32ProcessBindings + const free = vi.spyOn(koffi, 'free') + expect(waitForProcessExit(api, 60n as NativePtr)).toBe(42) + expect(free).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts similarity index 71% rename from packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts rename to packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index a6bea87998..9335fccbf0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -1,23 +1,28 @@ /** * Failure-path unit tests with minimal stub binding tables: the spawn * helpers must close every handle they created before throwing, and - * getTempPath must refuse to decode a buffer GetTempPathW never wrote. + * every generic process failure remains owned by the shared package. * Pure stubs — no real Win32 calls, so these run on every platform. */ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' -import type { NativePtr, Win32Bindings } from '../src/ffi.ts' -import { Win32Error } from '../src/errors.ts' -import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts' -import * as abi from '../src/win32-abi.ts' +import { + Win32Error, + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, + waitForProcessExit, +} from '../src/index.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' +import * as abi from '../src/abi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' const PVOID = koffi.pointer('void') /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ -function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { +function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType } { const closed: bigint[] = [] let next = 1n const closeHandle = vi.fn((handle: NativePtr) => { @@ -35,18 +40,23 @@ function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return { api, closed, closeHandle } } /** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */ -function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { +function resumeFailureApi(): { + api: Win32ProcessBindings + closed: bigint[] + closeHandle: ReturnType +} { const closed: bigint[] = [] let std = 50n const closeHandle = vi.fn((handle: NativePtr) => { closed.push(handle) return 1 }) + const resumeThread = vi.fn(() => 0xFFFFFFFF) const api = { createJobObjectW: vi.fn(() => 100n), setInformationJobObject: vi.fn(() => 1), @@ -60,11 +70,11 @@ function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle return 1 }), assignProcessToJobObject: vi.fn(() => 1), - resumeThread: vi.fn(() => 0xFFFFFFFF), + resumeThread, getLastError: vi.fn(() => 5), closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return { api, closed, closeHandle } } @@ -72,11 +82,11 @@ describe('spawn failure paths close their handles', () => { // A dummy token value; the stubbed spawn never reads it. const token = 1n as NativePtr - it('spawnSandboxed closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => { + it('closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => { const { api, closed, closeHandle } = pipeFailureApi() let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -87,11 +97,11 @@ describe('spawn failure paths close their handles', () => { expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n]) }) - it('spawnSandboxedInherited closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { + it('closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { const { api, closed, closeHandle } = resumeFailureApi() let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -104,43 +114,11 @@ describe('spawn failure paths close their handles', () => { expect(closed).toEqual([201n, 200n, 100n]) }) - it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => { - // The child is created suspended and is NOT in the kill-on-close job when - // the assignment fails: closing the job cannot kill it, so the failure - // branch must TerminateProcess first or every failure strands a hanging - // orphan forever. - const { api: baseApi, closeHandle } = resumeFailureApi() - type JobFailureApi = Win32Bindings & { - assignProcessToJobObject: ReturnType - terminateProcess: ReturnType - } - const api = baseApi as JobFailureApi - api.assignProcessToJobObject = vi.fn(() => 0) - api.terminateProcess = vi.fn(() => 1) - let caught: unknown - try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(Win32Error) - expect((caught as Win32Error).api).toBe('AssignProcessToJobObject') - expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1) - // thread, process, job — and the child is already dead before they close. - expect(closeHandle).toHaveBeenCalledTimes(3) - }) -}) - -describe('getTempPath buffer defense', () => { - it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => { - const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer - expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) - }) }) /** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */ -function pipeOkApi(overrides: Partial = {}): { - api: Win32Bindings +function pipeOkApi(overrides: Partial = {}): { + api: Win32ProcessBindings closed: bigint[] closeHandle: ReturnType } { @@ -168,18 +146,22 @@ function pipeOkApi(overrides: Partial = {}): { closeHandle, formatMessageW: vi.fn(() => 0), ...overrides, - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings 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 + it('reports a CreatePipe failure', () => { + const api = { + createPipe: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -187,11 +169,15 @@ describe('spawn pipe failures close their handles', () => { 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 + it('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 Win32ProcessBindings let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -199,11 +185,11 @@ describe('spawn pipe failures close their handles', () => { expect((caught as Win32Error).api).toBe('CreatePipe') }) - it('spawnSandboxed reports a SetHandleInformation failure', () => { + it('reports a SetHandleInformation failure', () => { const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) }) let caught: unknown try { - spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -211,7 +197,7 @@ describe('spawn pipe failures close their handles', () => { expect((caught as Win32Error).api).toBe('SetHandleInformation') }) - it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => { + it('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, @@ -221,17 +207,17 @@ describe('spawn pipe failures close their handles', () => { return 1 }), }) - expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + expect(() => spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) .toThrow(/null process\/thread handles/u) }) }) -describe('spawnSandboxedInherited failure paths', () => { +describe('spawnInheritedJobProcess failure paths', () => { const token = 1n as NativePtr /** The stub the inherited-happy path needs; overrides flip one call per test. */ - function inheritedApi(overrides: Partial = {}): { - api: Win32Bindings + function inheritedApi(overrides: Partial = {}): { + api: Win32ProcessBindings closed: bigint[] closeHandle: ReturnType } { @@ -259,7 +245,7 @@ describe('spawnSandboxedInherited failure paths', () => { closeHandle, formatMessageW: vi.fn(() => 0), ...overrides, - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return { api, closed, closeHandle } } @@ -267,7 +253,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -280,7 +266,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -292,7 +278,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -311,16 +297,35 @@ describe('spawnSandboxedInherited failure paths', () => { return 1 }), }) - expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) .toThrow(/null process\/thread handles/u) expect(closeHandle).toHaveBeenCalledWith(100n) }) + it('terminates the suspended child when Job assignment fails', () => { + const terminateProcess = vi.fn(() => 1) + const { api, closeHandle } = inheritedApi({ + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, + }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 }) + expect(terminateProcess).toHaveBeenCalledWith(200n, 1) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) + 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:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -333,7 +338,7 @@ describe('spawnSandboxedInherited failure paths', () => { const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) }) let caught: unknown try { - spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) } catch (error) { caught = error } @@ -343,7 +348,7 @@ describe('spawnSandboxedInherited failure paths', () => { 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:\\' }) + const spawned = spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) expect(spawned.pid).toBe(1234) expect(spawned.process).toBe(200n) expect(spawned.job).toBe(100n) @@ -362,7 +367,7 @@ describe('drainPipe', () => { getLastError: vi.fn(() => abi.ERROR_NO_DATA), closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return drainPipe(api, 30n as NativePtr).then((buffer) => { expect(buffer.length).toBe(0) expect(closeHandle).toHaveBeenCalledWith(30n) @@ -370,13 +375,15 @@ describe('drainPipe', () => { }) it('reports a PeekNamedPipe failure that is not a clean EOF', () => { + const closeHandle = vi.fn(() => 1) const api = { peekNamedPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), - closeHandle: vi.fn(() => 1), + closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + .then(() => { expect(closeHandle).toHaveBeenCalledWith(30n) }) }) it('reports a ReadFile failure after data was reported available', () => { @@ -389,7 +396,7 @@ describe('drainPipe', () => { getLastError: vi.fn(() => 5), closeHandle: vi.fn(() => 1), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' }) }) @@ -410,31 +417,37 @@ describe('drainPipe', () => { getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE), closeHandle: vi.fn(() => 1), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + } as unknown as Win32ProcessBindings return drainPipe(api, 30n as NativePtr).then((buffer) => { expect(buffer.toString('utf8')).toBe('ab') }) }) }) -describe('waitForExit', () => { +describe('waitForProcessExit', () => { it('reports a WaitForSingleObject failure', () => { + const closeHandle = vi.fn(() => 1) const api = { waitForSingleObject: vi.fn(() => 0xFFFFFFFF), getLastError: vi.fn(() => 5), + closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings - expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + } as unknown as Win32ProcessBindings + expect(() => waitForProcessExit(api, 200n as NativePtr)).toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(200n) }) it('reports a GetExitCodeProcess failure', () => { + const closeHandle = vi.fn(() => 1) const api = { waitForSingleObject: vi.fn(() => 0), getExitCodeProcess: vi.fn(() => 0), getLastError: vi.fn(() => 5), + closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings - expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + } as unknown as Win32ProcessBindings + expect(() => waitForProcessExit(api, 200n as NativePtr)).toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(200n) }) it('returns the exit code and closes the process handle', () => { @@ -447,8 +460,8 @@ describe('waitForExit', () => { }), closeHandle, formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings - expect(waitForExit(api, 200n as NativePtr)).toBe(42) + } as unknown as Win32ProcessBindings + expect(waitForProcessExit(api, 200n as NativePtr)).toBe(42) expect(closeHandle).toHaveBeenCalledWith(200n) }) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts new file mode 100644 index 0000000000..ecee195b2c --- /dev/null +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -0,0 +1,243 @@ +import koffi from 'koffi' +import { describe, expect, it, vi } from 'vitest' +import { + Win32Error, + closeHandleChecked, + drainPipe, + spawnInheritedJobProcess, + spawnPipedProcess, +} from '../src/index.ts' +import { CREATE_SUSPENDED } from '../src/abi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' + +const PVOID = koffi.pointer('void') + +function inheritedApi(overrides: Partial = {}): { + api: Win32ProcessBindings + events: string[] + createProcessAsUserW: ReturnType + assignProcessToJobObject: ReturnType +} { + const events: string[] = [] + const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = + overrides.createProcessAsUserW + ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + events.push('create') + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }) + const createProcessAsUserW = vi.fn(createProcessAsUserWImpl) + const assignProcessToJobObject = vi.fn(() => { events.push('assign'); return 1 }) + const api = { + createJobObjectW: vi.fn(() => 50n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + setHandleInformation: vi.fn((_handle: NativePtr, _mask: number, flags: number) => { + events.push(flags === 0 ? 'restore' : 'inherit') + return 1 + }), + assignProcessToJobObject, + resumeThread: vi.fn(() => { events.push('resume'); return 1 }), + terminateProcess: vi.fn(() => 1), + closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + ...overrides, + createProcessAsUserW, + } as unknown as Win32ProcessBindings + return { + api, + events, + createProcessAsUserW, + assignProcessToJobObject, + } +} + +describe('spawnInheritedJobProcess', () => { + const token = 70n as NativePtr + + it('attaches a restricted suspended child to the Job inside CreateProcessAsUserW', () => { + const { + api, + events, + createProcessAsUserW, + assignProcessToJobObject, + } = inheritedApi() + const child = spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: ['/c', 'exit', '0'], + cwd: 'C:\\work', + token, + }) + expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) + expect(events.indexOf('assign')).toBeGreaterThan(events.indexOf('create')) + expect(events.indexOf('resume')).toBeGreaterThan(events.indexOf('create')) + expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n) + expect(createProcessAsUserW).toHaveBeenCalledWith( + token, + null, + 'cmd.exe /c exit 0', + null, + null, + 1, + CREATE_SUSPENDED, + null, + 'C:\\work', + expect.anything(), + expect.anything(), + ) + }) + + it('restores already-enabled stdio and closes the Job when inheritance setup fails', () => { + let calls = 0 + const closeHandle = vi.fn(() => 1) + const setHandleInformation = vi.fn((_handle: NativePtr, _mask: number, flags: number) => { + if (flags === 0) return 1 + calls += 1 + return calls === 2 ? 0 : 1 + }) + const { api } = inheritedApi({ closeHandle, setHandleInformation }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(setHandleInformation).toHaveBeenCalledWith(expect.anything(), 1, 0) + expect(closeHandle).toHaveBeenCalledWith(50n) + }) + + it('captures a GetStdHandle error before Job cleanup changes last-error', () => { + let lastError = 123 + const { api } = inheritedApi({ + getStdHandle: vi.fn(() => 0n as NativePtr), + getLastError: vi.fn(() => lastError), + closeHandle: vi.fn(() => { lastError = 999; return 1 }), + }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'cmd.exe', args: [], cwd: 'C:\\work', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'GetStdHandle', win32Code: 123 }) + }) + + it('captures a CreateProcess error before inheritance restoration changes last-error', () => { + let lastError = 87 + const { api } = inheritedApi({ + createProcessAsUserW: vi.fn(() => 0), + getLastError: vi.fn(() => lastError), + setHandleInformation: vi.fn((_handle, _mask, flags) => { + if (flags === 0) lastError = 999 + return 1 + }), + closeHandle: vi.fn(() => { lastError = 998; return 1 }), + }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'cmd.exe', args: [], cwd: 'C:\\work', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 }) + }) + + it('terminates a restricted child when CreateProcessAsUserW returns a null thread handle', () => { + const terminateProcess = vi.fn(() => 1) + const { api } = inheritedApi({ + terminateProcess, + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 0n, + dwProcessId: 1234, + dwThreadId: 0, + }) + return 1 + }), + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow('null process/thread handles') + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + }) +}) + +describe('wait and pipe cleanup', () => { + const token = 70n as NativePtr + + it('waits when a pipe is temporarily empty before observing EOF', async () => { + const closeHandle = vi.fn(() => 1) + let peeks = 0 + const api = { + peekNamedPipe: vi.fn((_handle, _buffer, _size, _read, available) => { + peeks += 1 + if (peeks === 1) { + koffi.encode(available, 'uint32', 0) + return 1 + } + return 0 + }), + getLastError: vi.fn(() => 109), + closeHandle, + } as unknown as Win32ProcessBindings + await expect(drainPipe(api, 80n as NativePtr)).resolves.toEqual(Buffer.alloc(0)) + expect(closeHandle).toHaveBeenCalledWith(80n) + }) + + it('checks caller-owned handle closure', () => { + const closeHandle = vi.fn(() => 1) + const api = { closeHandle } as unknown as Win32ProcessBindings + expect(() => { closeHandleChecked(api, 80n as NativePtr, 'sandbox Job') }).not.toThrow() + expect(closeHandle).toHaveBeenCalledWith(80n) + + const failing = { + closeHandle: vi.fn(() => 0), + getLastError: vi.fn(() => 6), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + expect(() => { closeHandleChecked(failing, 81n as NativePtr, 'sandbox Job') }).toThrow(Win32Error) + }) + + it('terminates a piped child when CreateProcess returns a null thread handle', () => { + let nextPipe = 10n + const terminateProcess = vi.fn(() => 1) + const closeHandle = vi.fn(() => 1) + const api = { + createPipe: vi.fn((readSlot, writeSlot) => { + koffi.encode(readSlot, PVOID, nextPipe++) + koffi.encode(writeSlot, PVOID, nextPipe++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 0n, + dwProcessId: 1234, + dwThreadId: 0, + }) + return 1 + }), + terminateProcess, + closeHandle, + } as unknown as Win32ProcessBindings + expect(() => spawnPipedProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow('null process/thread handles') + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + }) +}) diff --git a/packages/subprocess/win32-process/tests/quote.spec.ts b/packages/subprocess/win32-process/tests/quote.spec.ts new file mode 100644 index 0000000000..63dcc7bc77 --- /dev/null +++ b/packages/subprocess/win32-process/tests/quote.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { buildCommandLine, quoteArg } from '../src/process.ts' + +const isWin32 = process.platform === 'win32' + +const cases: Array<[string, string]> = [ + ['', '""'], + ['a', 'a'], + ['a b', '"a b"'], + ['a"b', '"a\\"b"'], + ['a\\b', 'a\\b'], + ['a b\\', '"a b\\\\"'], + ['a b\\\\', '"a b\\\\\\\\"'], + ['a\\\\"b', '"a\\\\\\\\\\"b"'], +] + +describe('quoteArg', () => { + it.each(cases)('quotes %j as %j', (input, expected) => { + expect(quoteArg(input)).toBe(expected) + }) + + it('builds one CreateProcess command line without shell interpretation', () => { + expect(buildCommandLine('C:\\Program Files\\tool.exe', ['a b', 'c'])).toBe( + '"C:\\Program Files\\tool.exe" "a b" c', + ) + }) +}) + +describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => { + it('parses the shared command line back to the original argv', async () => { + const { default: koffi } = await import('koffi') + const PVOID = koffi.pointer('void') + const shell32 = koffi.load('shell32.dll') + const kernel32 = koffi.load('kernel32.dll') + const commandLineToArgvW = shell32.func( + '__stdcall', + 'CommandLineToArgvW', + PVOID, + ['str16', koffi.pointer('int')], + ) + const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int']) + const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID]) + const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID]) + const parse = (commandLine: string): string[] => { + const countSlot = koffi.alloc('int', 1) as unknown + const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown + try { + if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL') + const count = koffi.decode(countSlot, 0, 'int') as number + const table = Buffer.from(koffi.view(argvBlock, count * 8)) + return Array.from({ length: count }, (_, index) => { + const stringAddress = table.readBigUInt64LE(index * 8) + const copied = Buffer.alloc(2048) + lstrcpynW(copied, stringAddress, copied.length / 2) + const length = lstrlenW(copied) as number + return copied.subarray(0, length * 2).toString('utf16le') + }) + } finally { + localFree(argvBlock) + } + } + const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a\\\\"b'] + expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv]) + }) +}) diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json new file mode 100644 index 0000000000..2f159cfc48 --- /dev/null +++ b/packages/subprocess/win32-process/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp new file mode 100644 index 0000000000..347fafdd3c --- /dev/null +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -0,0 +1,48 @@ +#include +#include +#include + +#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr)) + +int wmain() +{ + P(sizeof(void*)); + P(sizeof(HANDLE)); + P(sizeof(STARTUPINFOW)); + P(offsetof(STARTUPINFOW, dwFlags)); + P(offsetof(STARTUPINFOW, hStdInput)); + P(offsetof(STARTUPINFOW, hStdOutput)); + P(offsetof(STARTUPINFOW, hStdError)); + P(sizeof(PROCESS_INFORMATION)); + P(offsetof(PROCESS_INFORMATION, hProcess)); + P(offsetof(PROCESS_INFORMATION, hThread)); + P(offsetof(PROCESS_INFORMATION, dwProcessId)); + P(CREATE_SUSPENDED); + P(STARTF_USESTDHANDLES); + P(HANDLE_FLAG_INHERIT); + P(INFINITE); + P(STD_INPUT_HANDLE); + P(STD_OUTPUT_HANDLE); + P(STD_ERROR_HANDLE); + P(FORMAT_MESSAGE_FROM_SYSTEM); + P(FORMAT_MESSAGE_IGNORE_INSERTS); + P(ERROR_INSUFFICIENT_BUFFER); + P(ERROR_BROKEN_PIPE); + P(ERROR_NO_DATA); + P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); + P((int)JobObjectExtendedLimitInformation); + P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + + static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); + static_assert(CREATE_SUSPENDED == 0x4, "create suspended"); + static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); + static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); + static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); + static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); + static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); + static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); + printf("\nstatic_asserts passed\n"); + return 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96bfb5d0de..6f489c33e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5799,6 +5799,9 @@ importers: packages/sandbox/sandbox-windows-acl: dependencies: + '@deepseek-ai/dsh-win32-process': + specifier: workspace:^ + version: link:../../subprocess/win32-process koffi: specifier: ^3.1.0 version: 3.1.1 @@ -7613,6 +7616,19 @@ importers: specifier: workspace:^ version: link:../../util/timeout + packages/subprocess/win32-process: + dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/terminal/terminal: devDependencies: '@deepseek-ai/cordis': diff --git a/tsconfig.host.json b/tsconfig.host.json index c95fcd91e0..938ad3372e 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -191,6 +191,7 @@ { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/subprocess/subprocess" }, { "path": "./packages/subprocess/subprocess-local" }, + { "path": "./packages/subprocess/win32-process" }, { "path": "./packages/e2b/e2b" }, { "path": "./packages/e2b/subprocess-e2b" }, { "path": "./packages/shell/shell" }, From e18564de03869987532c7ccab13500c2090b2758 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:11:59 +0800 Subject: [PATCH 02/23] chore(sandbox): align inherited wait lint --- .../sandbox/sandbox-windows-acl/src/index.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 40d0d47a1d..e0bb8b2323 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -358,24 +358,27 @@ export class AclSandbox { let settlement: Promise | undefined return { pid: native.pid, - // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. - wait: () => (settlement ??= (async () => { - const failures: unknown[] = [] - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - try { - closeHandleChecked(api, native.job, 'kill-on-close job') - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') - return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } - })()), + wait: () => { + // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. + settlement ??= (async () => { + const failures: unknown[] = [] + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + try { + closeHandleChecked(api, native.job, 'kill-on-close job') + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + })() + return settlement + }, } } From f1fd304dffb4f4495713d1e7bf329b274d5f9200 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:14:07 +0800 Subject: [PATCH 03/23] fix(sandbox): memoize inherited settlement promise --- .../sandbox/sandbox-windows-acl/src/index.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index e0bb8b2323..10fe48be45 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -358,27 +358,23 @@ export class AclSandbox { let settlement: Promise | undefined return { pid: native.pid, - wait: () => { - // oxlint-disable-next-line typescript/require-await -- Memoize one promise over synchronous native wait and cleanup. - settlement ??= (async () => { - const failures: unknown[] = [] - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - try { - closeHandleChecked(api, native.job, 'kill-on-close job') - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') - return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } - })() - return settlement - }, + wait: () => (settlement ??= new Promise((resolveResult) => { + const failures: unknown[] = [] + let exitCode = 0 + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } + try { + closeHandleChecked(api, native.job, 'kill-on-close job') + } catch (error) { + failures.push(error) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') + resolveResult({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode }) + })), } } From ab494bfdcaa11f839037c63a9ca43a32fdad7459 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 04:39:31 +0800 Subject: [PATCH 04/23] refactor(win32-process): narrow PR1 native surface --- .../sandbox-windows-acl/tests/ffi.spec.ts | 63 ++----------------- packages/subprocess/win32-process/src/ffi.ts | 2 +- 2 files changed, 6 insertions(+), 59 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 02dbbb82b4..7c046ae87f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -1,7 +1,7 @@ /** * Sandbox-specific FFI tests with stub binding tables: temp-path decoding, - * last-error throwers' detail fallback, pointer decode NULL handling, and - * the bounded SID comparison's early exits. Pure stubs — no real Win32 + * invalid-handle checks, pointer-at-offset decoding, 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). */ @@ -11,14 +11,12 @@ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { - allocBytes, decodePtr, decodePtrAt, getTempPath, - isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32, + allocBytes, decodePtrAt, getTempPath, + isInvalidHandle, sameSidAt, } 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 } { const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => { @@ -77,53 +75,7 @@ describe('getTempPath', () => { }) }) -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) - }) - +describe('sandbox pointer handling', () => { it('isInvalidHandle treats NULL as failure', () => { expect(isInvalidHandle(null)).toBe(true) expect(isInvalidHandle(undefined)).toBe(true) @@ -142,11 +94,6 @@ describe('pointer NULL handling', () => { 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', () => { diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 38b3e32200..b905fb1975 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -70,7 +70,7 @@ export interface Win32ProcessBindings { setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, - applicationName: string | null, + applicationName: null, commandLine: string, processAttributes: null, threadAttributes: null, From 4a722de4fa0218295ea31f9c43346e9818d3d5af Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:03:59 +0800 Subject: [PATCH 05/23] fix(win32-process): close PR1 review gaps --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 6 +- ...8-19-shared-win32-process-primitives.zh.md | 6 +- .github/workflows/ci.yml | 16 +++ .../sandbox-local/tests/packed-install.e2e.ts | 1 + .../sandbox/sandbox-windows-acl/src/index.ts | 1 + .../sandbox-windows-acl/tests/ffi.spec.ts | 9 +- .../tests/index-failure-paths.spec.ts | 13 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- packages/subprocess/win32-process/src/abi.ts | 8 ++ packages/subprocess/win32-process/src/ffi.ts | 25 +++- .../subprocess/win32-process/src/index.ts | 1 + .../win32-process/src/job-attribute.ts | 124 ++++++++++++++++++ .../subprocess/win32-process/src/process.ts | 50 +++---- .../win32-process/tests/job-attribute.spec.ts | 65 +++++++++ .../tests/process-allocation-failure.spec.ts | 25 +++- .../tests/process-failure-paths.spec.ts | 58 ++++++-- .../win32-process/tests/process.spec.ts | 68 ++++++++-- .../win32-process/tests/quote.spec.ts | 3 +- .../win32-process/verify/abi-probe.cpp | 8 ++ scripts/ci-workflow.spec.ts | 4 + 26 files changed, 435 insertions(+), 80 deletions(-) create mode 100644 packages/subprocess/win32-process/src/job-attribute.ts create mode 100644 packages/subprocess/win32-process/tests/job-attribute.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 053fadffc3..1eda0cef7b 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: ab23b02dfb4e937891b26b009900696ada3fa3c0 -2026-08-19-shared-win32-process-primitives.zh.md: e8686d9f4d1ac2d05c0eecf025ada19d491e50d2 +2026-08-19-shared-win32-process-primitives.md: 58bbd5a2ae44caf85dfca144d99efabb063243e2 +2026-08-19-shared-win32-process-primitives.zh.md: 5c4e63979412fbb617094ad1745f4a8318b49237 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index ab23b02dfb..58bbd5a2ae 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Successful inherited-stdio creation returns the process plus kill-on-close Job after the child is suspended, assigned to the Job, and resumed; assignment failure terminates the suspended child before releasing its handles. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so a successfully created suspended child is already Job-owned before resume; attribute, creation, or resume failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended Job assignment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile the header probe and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic suspended Job attachment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index e8686d9f4d..5c4e639794 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW`、`STARTUPINFOEXW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建成功时,child 已 suspended、指派给 Job 并 resume,随后把 process 与 kill-on-close Job 返回给 sandbox;指派失败会先终止 suspended child,再释放其 handles。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此成功创建的 suspended child 在 resume 前已经归属 Job;attribute、创建或 resume 失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的 suspended Job 指派、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的原子 suspended Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cab0cf791..e945879e1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -488,6 +488,22 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile + - name: Compile and run Win32 header ABI probes + shell: pwsh + run: | + $probeRoot = Join-Path $env:RUNNER_TEMP 'dsh-win32-abi-probes' + New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null + $processProbe = Join-Path $probeRoot 'win32-process.exe' + $sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' + g++ -std=c++20 -municode -O2 -o $processProbe packages/subprocess/win32-process/verify/abi-probe.cpp + if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe compilation failed' } + & $processProbe + if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe failed' } + g++ -std=c++20 -municode -O2 -o $sandboxProbe packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp -ladvapi32 + if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe compilation failed' } + & $sandboxProbe + if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe failed' } + - name: Run complete native Windows gate inventory shell: pwsh run: pnpm run check:ci:windows-complete diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index eded2b8d70..135ebb8494 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -32,6 +32,7 @@ const WORKSPACE_CLOSURE = [ // consumer resolves it like any other @deepseek-ai peer (koffi arrives // from the registry). 'packages/sandbox/sandbox-windows-acl', + 'packages/subprocess/win32-process', 'packages/sandbox/sandbox', 'packages/core/session', 'packages/core/scope', diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 10fe48be45..5a46eb1423 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -55,6 +55,7 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' +export { quoteArg, Win32Error } from '@deepseek-ai/dsh-win32-process' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 7c046ae87f..ebd3ba1b35 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -6,10 +6,10 @@ * acl.spec.ts and probe.spec.ts (win32 only). */ -import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' +import { Win32Error, quoteArg } from '../src/index.ts' import { allocBytes, decodePtrAt, getTempPath, isInvalidHandle, sameSidAt, @@ -75,6 +75,13 @@ describe('getTempPath', () => { }) }) +describe('public compatibility exports', () => { + it('keeps the sandbox Win32 error and quoting API', () => { + expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error) + expect(quoteArg('a b')).toBe('"a b"') + }) +}) + describe('sandbox pointer handling', () => { it('isInvalidHandle treats NULL as failure', () => { expect(isInvalidHandle(null)).toBe(true) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index adc1aaa8a1..4f0957ea65 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -145,7 +145,15 @@ function happyStubs(): HappyStubs { }) const createJobObjectW = vi.fn(() => fresh()) const setInformationJobObject = vi.fn(() => 1) - const assignProcessToJobObject = vi.fn(() => 1) + const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }) + const updateProcThreadAttribute = vi.fn(() => 1) + const deleteProcThreadAttributeList = vi.fn() const resumeThread = vi.fn(() => 0) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) @@ -160,7 +168,8 @@ function happyStubs(): HappyStubs { getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, - setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle, + setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute, + deleteProcThreadAttributeList, resumeThread, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 7cdeda55c3..62da073509 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/README.md -README.md: ba74f0d2ed2251c3527259b571663abf5bf740a2 -README.zh.md: fefc13d49b94ddba2e697d3481b4b991531540a8 +README.md: 56d6c04af92fa07673e3f8881bf20e47358fd001 +README.zh.md: 8b7db95e0196dbc98a67657478e4e242b4f7bca9 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index ba74f0d2ed..56d6c04af9 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,7 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | -| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, Job assignment, waits, and handle cleanup | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, atomic Job attachment, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index fefc13d49b..8b7db95e01 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | -| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、Job 指派、wait 与句柄清理的唯一 Koffi owner | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、原子 Job 附加、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 6e68e09233..909e34fecb 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md -README.md: a18b1b8167e3ea76d61f022f4aa3ea827546d93f -README.zh.md: 262300f5da48aeed4fe7c05d970d498147921c49 +README.md: 53064791de5375cd05008509db4f9d27beec3dc3 +README.zh.md: f5c6bc8334908241b5e6a2332f79e3d3808f6469 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index a18b1b8167..53064791de 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -6,10 +6,10 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. -- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, restores the parent handle flags, and resumes the child. Creation, assignment, or resume failure closes every owned resource; assignment failure terminates the still-suspended child before releasing its process and thread handles. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, attaches that Job through `STARTUPINFOEXW`, creates the restricted child suspended and already Job-owned, restores the parent handle flags, and resumes the child. Attribute setup, creation, or resume failure closes every owned resource; no successful process creation can leave an unowned suspended child. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 262300f5da..f5c6bc8334 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -6,10 +6,10 @@ ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 -- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,将其指派给 Job,恢复父进程句柄标志,再 resume child。创建、指派或 resume 失败都会关闭全部已拥有资源;指派失败会先终止仍 suspended 的 child,再释放其 process 与 thread handles。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,通过 `STARTUPINFOEXW` 附加该 Job,以 suspended 且已经归属 Job 的状态创建 restricted child,恢复父进程句柄标志,再 resume child。attribute 设置、创建或 resume 失败都会关闭全部已拥有资源;成功创建进程后不会留下无 owner 的 suspended child。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index fbdda9059f..168879bf65 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -8,6 +8,10 @@ export const HANDLE_FLAG_INHERIT = 0x1 export const INFINITE = 0xFFFFFFFF /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 +/** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */ +export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +/** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */ +export const PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -34,5 +38,9 @@ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 /** x64 STARTUPINFOW byte size verified by the native probe. */ export const STARTUPINFOW_SIZE = 104 +/** x64 STARTUPINFOEXW byte size verified by the native probe. */ +export const STARTUPINFOEXW_SIZE = 112 +/** x64 pointer and HANDLE byte size. */ +export const POINTER_SIZE = 8 /** x64 PROCESS_INFORMATION byte size verified by the native probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b905fb1975..b22f096bfc 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -81,6 +81,22 @@ export interface Win32ProcessBindings { startupInfo: NativePtr, processInfo: NativePtr, ): number + initializeProcThreadAttributeList( + attributeList: Buffer | null, + attributeCount: number, + flags: number, + size: NativePtr, + ): number + updateProcThreadAttribute( + attributeList: Buffer, + flags: number, + attribute: number, + value: NativePtr, + size: number, + previousValue: null, + returnSize: null, + ): number + deleteProcThreadAttributeList(attributeList: Buffer): void readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, @@ -95,7 +111,6 @@ export interface Win32ProcessBindings { resumeThread(thread: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number - assignProcessToJobObject(job: NativePtr, process: NativePtr): number terminateProcess(process: NativePtr, exitCode: number): number getStdHandle(stdHandle: number): NativePtr } @@ -241,6 +256,13 @@ function bindings(): Win32ProcessBindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), + initializeProcThreadAttributeList: bind(kernel32, 'InitializeProcThreadAttributeList', 'int', [ + PVOID, 'uint32', 'uint32', koffi.pointer('size_t'), + ]), + updateProcThreadAttribute: bind(kernel32, 'UpdateProcThreadAttribute', 'int', [ + PVOID, 'uint32', 'size_t', PVOID, 'size_t', PVOID, PVOID, + ]), + deleteProcThreadAttributeList: bind(kernel32, 'DeleteProcThreadAttributeList', 'void', [PVOID]), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), @@ -250,7 +272,6 @@ function bindings(): Win32ProcessBindings { resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), - assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32ProcessBindings diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index fa7f2dd992..e7693db495 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -19,6 +19,7 @@ export type { export { closeHandleChecked, drainPipe, + quoteArg, spawnInheritedJobProcess, spawnPipedProcess, waitForProcessExit, diff --git a/packages/subprocess/win32-process/src/job-attribute.ts b/packages/subprocess/win32-process/src/job-attribute.ts new file mode 100644 index 0000000000..7798cc6eea --- /dev/null +++ b/packages/subprocess/win32-process/src/job-attribute.ts @@ -0,0 +1,124 @@ +/** Package-private STARTUPINFOEXW ownership for atomic Job attachment. */ + +import koffi from 'koffi' +import * as abi from './abi.ts' +import { STARTUPINFOW, throwWin32 } from './ffi.ts' +import type { NativePtr, StartupInfoInput, Win32ProcessBindings } from './ffi.ts' + +type Ptr = ReturnType +const PVOID: Ptr = koffi.pointer('void') + +const STARTUPINFOEXW = koffi.struct('DSH_STARTUPINFOEXW', { + StartupInfo: STARTUPINFOW, + lpAttributeList: PVOID, +}) + +/* v8 ignore start -- the native header probe pins this x64 layout. */ +if (STARTUPINFOEXW.size !== abi.STARTUPINFOEXW_SIZE) { + throw new Error(`STARTUPINFOEXW layout mismatch: koffi computed ${STARTUPINFOEXW.size}, expected ${abi.STARTUPINFOEXW_SIZE}`) +} +/* v8 ignore stop */ + +/** One extended startup record whose attribute list remains valid through CreateProcess. */ +export interface JobStartupInfo { + /** STARTUPINFOEXW pointer passed to CreateProcessAsUserW. */ + readonly pointer: NativePtr + /** Release the initialized process attribute list after CreateProcessAsUserW returns. */ + dispose(): void +} + +function queryAttributeListSize(api: Win32ProcessBindings): number { + const sizeSlot = koffi.alloc('size_t', 1) as NativePtr + try { + api.initializeProcThreadAttributeList(null, 1, 0, sizeSlot) + const attributeBytes = koffi.decode(sizeSlot, 'size_t') as number + if (attributeBytes === 0) { + throwWin32( + api, + 'InitializeProcThreadAttributeList', + api.getLastError(), + 'process-attribute size query', + ) + } + return attributeBytes + } finally { + koffi.free(sizeSlot) + } +} + +/** + * Build a STARTUPINFOEXW that assigns the restricted child to `job` during creation. + * @param api - active binding table. + * @param fields - inherited stdio fields for the nested STARTUPINFOW. + * @param job - caller-owned Job attached before any child thread exists. + * @returns extended startup pointer and its post-CreateProcess disposer. + */ +export function createJobStartupInfo( + api: Win32ProcessBindings, + fields: Omit, + job: NativePtr, +): JobStartupInfo { + const attributeList = Buffer.alloc(queryAttributeListSize(api)) + const sizeSlot = koffi.alloc('size_t', 1) as NativePtr + let initialized = false + let jobList: NativePtr | undefined + try { + koffi.encode(sizeSlot, 'size_t', attributeList.length) + if (api.initializeProcThreadAttributeList(attributeList, 1, 0, sizeSlot) === 0) { + throwWin32( + api, + 'InitializeProcThreadAttributeList', + api.getLastError(), + 'process-attribute initialization', + ) + } + initialized = true + jobList = koffi.alloc(PVOID, 1) as NativePtr + koffi.encode(jobList, PVOID, job) + if (api.updateProcThreadAttribute( + attributeList, + 0, + abi.PROC_THREAD_ATTRIBUTE_JOB_LIST, + jobList, + abi.POINTER_SIZE, + null, + null, + ) === 0) { + throwWin32( + api, + 'UpdateProcThreadAttribute', + api.getLastError(), + 'PROC_THREAD_ATTRIBUTE_JOB_LIST', + ) + } + const pointer = koffi.alloc(STARTUPINFOEXW, 1) as NativePtr + try { + koffi.encode(pointer, STARTUPINFOEXW, { + StartupInfo: { ...fields, cb: abi.STARTUPINFOEXW_SIZE }, + lpAttributeList: attributeList, + }) + } catch (error) { + /* v8 ignore start -- staging a STARTUPINFOEXW encode failure requires replacing Koffi's encoder. */ + koffi.free(pointer) + throw error + /* v8 ignore stop */ + } + return { + pointer, + dispose: () => { + try { + api.deleteProcThreadAttributeList(attributeList) + } finally { + koffi.free(jobList) + koffi.free(pointer) + } + }, + } + } catch (error) { + if (initialized) api.deleteProcThreadAttributeList(attributeList) + if (jobList !== undefined) koffi.free(jobList) + throw error + } finally { + koffi.free(sizeSlot) + } +} diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index f62195413f..ff5d7a9386 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -15,6 +15,7 @@ import { throwLastError, throwWin32, } from './ffi.ts' +import { createJobStartupInfo } from './job-attribute.ts' import type { NativePtr, Win32ProcessBindings } from './ffi.ts' /** @@ -77,7 +78,7 @@ export interface SpawnedPipedProcess { stderrRead: NativePtr } -/** Suspended-created child assigned to one caller-owned kill-on-close Job before resume. */ +/** Suspended-created child atomically attached to one caller-owned kill-on-close Job. */ export interface SpawnedJobProcess { /** Direct child process id. */ pid: number @@ -310,7 +311,7 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { } /** - * Spawn suspended inside a kill-on-close Job, then resume. + * Spawn suspended and atomically attached to a kill-on-close Job, then resume. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. * @returns caller-owned process and Job handles after successful resume. @@ -331,7 +332,6 @@ export function spawnInheritedJobProcess( const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] - let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined let created = 0 let createFailureCode = 0 @@ -346,27 +346,28 @@ export function spawnInheritedJobProcess( } enabled.push(handle) } - startupInfo = allocStartupInfo() - encodeStartupInfo(startupInfo, { - cb: abi.STARTUPINFOW_SIZE, + const startupInfo = createJobStartupInfo(api, { dwFlags: abi.STARTF_USESTDHANDLES, hStdInput: stdIn, hStdOutput: stdOut, hStdError: stdErr, - }) - processInfo = allocProcessInfo() - created = createRestrictedProcess( - api, - options, - buildCommandLine(options.command, options.args), - abi.CREATE_SUSPENDED, - startupInfo, - processInfo, - ) - if (created === 0) createFailureCode = api.getLastError() + }, job) + try { + processInfo = allocProcessInfo() + created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + abi.CREATE_SUSPENDED | abi.EXTENDED_STARTUPINFO_PRESENT, + startupInfo.pointer, + processInfo, + ) + if (created === 0) createFailureCode = api.getLastError() + } finally { + startupInfo.dispose() + } } catch (error) { freeNative(processInfo) - freeNative(startupInfo) api.closeHandle(job) throw error } finally { @@ -374,7 +375,6 @@ export function spawnInheritedJobProcess( } if (created === 0) { freeNative(processInfo) - freeNative(startupInfo) api.closeHandle(job) throwWin32( api, @@ -388,23 +388,13 @@ export function spawnInheritedJobProcess( info = decodeProcessInfo(processInfo) } finally { freeNative(processInfo) - freeNative(startupInfo) } if (info.hProcess === null || info.hThread === null) { - if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) + api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) - api.closeHandle(job) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } - if (api.assignProcessToJobObject(job, info.hProcess) === 0) { - const win32Code = api.getLastError() - api.terminateProcess(info.hProcess, 1) - closeBestEffort(api, info.hThread) - closeBestEffort(api, info.hProcess) - api.closeHandle(job) - throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) - } if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { const win32Code = api.getLastError() closeBestEffort(api, info.hThread) diff --git a/packages/subprocess/win32-process/tests/job-attribute.spec.ts b/packages/subprocess/win32-process/tests/job-attribute.spec.ts new file mode 100644 index 0000000000..793cb62bdd --- /dev/null +++ b/packages/subprocess/win32-process/tests/job-attribute.spec.ts @@ -0,0 +1,65 @@ +import koffi from 'koffi' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createJobStartupInfo } from '../src/job-attribute.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts' + +afterEach(() => { + vi.restoreAllMocks() +}) + +function bindings(): { + api: Win32ProcessBindings + deleteProcThreadAttributeList: ReturnType +} { + const deleteProcThreadAttributeList = vi.fn() + const api = { + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32ProcessBindings + return { api, deleteProcThreadAttributeList } +} + +const fields = { + dwFlags: 0x100, + hStdInput: 1n as NativePtr, + hStdOutput: 2n as NativePtr, + hStdError: 3n as NativePtr, +} + +describe('createJobStartupInfo allocation cleanup', () => { + it('frees the size slot when attribute-list buffer allocation throws', () => { + const { api, deleteProcThreadAttributeList } = bindings() + const free = vi.spyOn(koffi, 'free') + vi.spyOn(Buffer, 'alloc').mockImplementationOnce(() => { throw new Error('buffer allocation failed') }) + expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('buffer allocation failed') + expect(free).toHaveBeenCalledOnce() + expect(deleteProcThreadAttributeList).not.toHaveBeenCalled() + }) + + it('deletes the initialized list and frees the Job value when attachment fails', () => { + const { api, deleteProcThreadAttributeList } = bindings() + api.updateProcThreadAttribute = vi.fn(() => 0) + const free = vi.spyOn(koffi, 'free') + expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('PROC_THREAD_ATTRIBUTE_JOB_LIST') + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(free).toHaveBeenCalledTimes(3) + }) + + it('frees every native allocation after the caller disposes the startup record', () => { + const { api, deleteProcThreadAttributeList } = bindings() + const free = vi.spyOn(koffi, 'free') + const startup = createJobStartupInfo(api, fields, 50n as NativePtr) + startup.dispose() + expect(free).toHaveBeenCalledTimes(4) + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts index 714af104b2..b850292b6a 100644 --- a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -20,11 +20,21 @@ afterEach(() => { describe('spawnInheritedJobProcess allocation cleanup', () => { it('frees startup info when process-info allocation throws', () => { + const deleteProcThreadAttributeList = vi.fn() const api = { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList, closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -37,7 +47,8 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toThrow('process-info allocation failed') - expect(free).toHaveBeenCalledOnce() + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(free).toHaveBeenCalledTimes(4) }) it('frees process info after a successful inherited spawn', () => { @@ -46,6 +57,15 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList: vi.fn(), createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -55,7 +75,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { }) return 1 }), - assignProcessToJobObject: vi.fn(() => 1), resumeThread: vi.fn(() => 1), closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), @@ -68,7 +87,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(free).toHaveBeenCalledTimes(2) + expect(free).toHaveBeenCalledTimes(5) }) }) diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 9335fccbf0..917974a868 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -21,6 +21,23 @@ import { PROCESS_INFORMATION } from '../src/ffi.ts' const PVOID = koffi.pointer('void') +function jobAttributeStubs(): Pick< + Win32ProcessBindings, + 'initializeProcThreadAttributeList' | 'updateProcThreadAttribute' | 'deleteProcThreadAttributeList' +> { + return { + initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 1 + }), + updateProcThreadAttribute: vi.fn(() => 1), + deleteProcThreadAttributeList: vi.fn(), + } +} + /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType } { const closed: bigint[] = [] @@ -69,7 +86,7 @@ function resumeFailureApi(): { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) return 1 }), - assignProcessToJobObject: vi.fn(() => 1), + ...jobAttributeStubs(), resumeThread, getLastError: vi.fn(() => 5), closeHandle, @@ -239,7 +256,7 @@ describe('spawnInheritedJobProcess failure paths', () => { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) return 1 }), - assignProcessToJobObject: vi.fn(() => 1), + ...jobAttributeStubs(), resumeThread: vi.fn(() => 0), getLastError: vi.fn(() => 5), closeHandle, @@ -302,11 +319,34 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(100n) }) - it('terminates the suspended child when Job assignment fails', () => { - const terminateProcess = vi.fn(() => 1) + it('closes the job when the attribute-list size query returns no size', () => { const { api, closeHandle } = inheritedApi({ - assignProcessToJobObject: vi.fn(() => 0), - terminateProcess, + initializeProcThreadAttributeList: vi.fn(() => 0), + }) + expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) + .toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job when attribute-list initialization fails', () => { + const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + koffi.encode(size, 'size_t', 64) + return 0 + } + return 0 + }) + const { api, closeHandle } = inheritedApi({ initializeProcThreadAttributeList }) + expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) + .toThrow(Win32Error) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('deletes the attribute list and closes the job when atomic Job attachment fails', () => { + const deleteProcThreadAttributeList = vi.fn() + const { api, closeHandle } = inheritedApi({ + updateProcThreadAttribute: vi.fn(() => 0), + deleteProcThreadAttributeList, }) let caught: unknown try { @@ -314,10 +354,8 @@ describe('spawnInheritedJobProcess failure paths', () => { } catch (error) { caught = error } - expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 }) - expect(terminateProcess).toHaveBeenCalledWith(200n, 1) - expect(closeHandle).toHaveBeenCalledWith(201n) - expect(closeHandle).toHaveBeenCalledWith(200n) + expect(caught).toMatchObject({ api: 'UpdateProcThreadAttribute', win32Code: 5 }) + expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() expect(closeHandle).toHaveBeenCalledWith(100n) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index ecee195b2c..b729c5d651 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -7,7 +7,12 @@ import { spawnInheritedJobProcess, spawnPipedProcess, } from '../src/index.ts' -import { CREATE_SUSPENDED } from '../src/abi.ts' +import { + CREATE_SUSPENDED, + EXTENDED_STARTUPINFO_PRESENT, + POINTER_SIZE, + PROC_THREAD_ATTRIBUTE_JOB_LIST, +} from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -17,9 +22,12 @@ function inheritedApi(overrides: Partial = {}): { api: Win32ProcessBindings events: string[] createProcessAsUserW: ReturnType - assignProcessToJobObject: ReturnType + initializeProcThreadAttributeList: ReturnType + updateProcThreadAttribute: ReturnType + attachedJob: () => NativePtr | null } { const events: string[] = [] + let attachedJob: NativePtr | null = null const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = overrides.createProcessAsUserW ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { @@ -33,7 +41,22 @@ function inheritedApi(overrides: Partial = {}): { return 1 }) const createProcessAsUserW = vi.fn(createProcessAsUserWImpl) - const assignProcessToJobObject = vi.fn(() => { events.push('assign'); return 1 }) + const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { + if (list === null) { + events.push('attribute-size') + koffi.encode(size, 'size_t', 64) + return 0 + } + events.push('attribute-init') + return 1 + }) + const updateProcThreadAttribute = vi.fn((_list, _flags, attribute: number, value: NativePtr) => { + if (attribute === PROC_THREAD_ATTRIBUTE_JOB_LIST) { + attachedJob = koffi.decode(value, PVOID) as NativePtr + events.push('attach-job') + } + return 1 + }) const api = { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), @@ -42,7 +65,9 @@ function inheritedApi(overrides: Partial = {}): { events.push(flags === 0 ? 'restore' : 'inherit') return 1 }), - assignProcessToJobObject, + initializeProcThreadAttributeList, + updateProcThreadAttribute, + deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }), resumeThread: vi.fn(() => { events.push('resume'); return 1 }), terminateProcess: vi.fn(() => 1), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), @@ -55,7 +80,9 @@ function inheritedApi(overrides: Partial = {}): { api, events, createProcessAsUserW, - assignProcessToJobObject, + initializeProcThreadAttributeList, + updateProcThreadAttribute, + attachedJob: () => attachedJob, } } @@ -67,7 +94,9 @@ describe('spawnInheritedJobProcess', () => { api, events, createProcessAsUserW, - assignProcessToJobObject, + initializeProcThreadAttributeList, + updateProcThreadAttribute, + attachedJob, } = inheritedApi() const child = spawnInheritedJobProcess(api, { command: 'cmd.exe', @@ -76,9 +105,21 @@ describe('spawnInheritedJobProcess', () => { token, }) expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(events.indexOf('assign')).toBeGreaterThan(events.indexOf('create')) + expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create')) + expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create')) expect(events.indexOf('resume')).toBeGreaterThan(events.indexOf('create')) - expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n) + expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything()) + expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything()) + expect(updateProcThreadAttribute).toHaveBeenCalledWith( + expect.any(Buffer), + 0, + PROC_THREAD_ATTRIBUTE_JOB_LIST, + expect.anything(), + POINTER_SIZE, + null, + null, + ) + expect(attachedJob()).toBe(50n) expect(createProcessAsUserW).toHaveBeenCalledWith( token, null, @@ -86,7 +127,7 @@ describe('spawnInheritedJobProcess', () => { null, null, 1, - CREATE_SUSPENDED, + CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, null, 'C:\\work', expect.anything(), @@ -149,10 +190,10 @@ describe('spawnInheritedJobProcess', () => { expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 }) }) - it('terminates a restricted child when CreateProcessAsUserW returns a null thread handle', () => { - const terminateProcess = vi.fn(() => 1) + it('closes the atomic Job when CreateProcessAsUserW returns a null thread handle', () => { + const closeHandle = vi.fn(() => 1) const { api } = inheritedApi({ - terminateProcess, + closeHandle, createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -169,7 +210,8 @@ describe('spawnInheritedJobProcess', () => { cwd: 'C:\\work', token, })).toThrow('null process/thread handles') - expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + expect(closeHandle).toHaveBeenCalledWith(50n) + expect(closeHandle).toHaveBeenCalledWith(60n) }) }) diff --git a/packages/subprocess/win32-process/tests/quote.spec.ts b/packages/subprocess/win32-process/tests/quote.spec.ts index 63dcc7bc77..f93d721cc3 100644 --- a/packages/subprocess/win32-process/tests/quote.spec.ts +++ b/packages/subprocess/win32-process/tests/quote.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildCommandLine, quoteArg } from '../src/process.ts' +import { quoteArg } from '../src/index.ts' +import { buildCommandLine } from '../src/process.ts' const isWin32 = process.platform === 'win32' diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 347fafdd3c..0b0911ea93 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -13,11 +13,15 @@ int wmain() P(offsetof(STARTUPINFOW, hStdInput)); P(offsetof(STARTUPINFOW, hStdOutput)); P(offsetof(STARTUPINFOW, hStdError)); + P(sizeof(STARTUPINFOEXW)); + P(offsetof(STARTUPINFOEXW, lpAttributeList)); P(sizeof(PROCESS_INFORMATION)); P(offsetof(PROCESS_INFORMATION, hProcess)); P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); P(CREATE_SUSPENDED); + P(EXTENDED_STARTUPINFO_PRESENT); + P(PROC_THREAD_ATTRIBUTE_JOB_LIST); P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); @@ -35,8 +39,12 @@ int wmain() P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size"); + static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); static_assert(CREATE_SUSPENDED == 0x4, "create suspended"); + static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag"); + static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 45712860e5..df60db0626 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -78,6 +78,10 @@ describe('CI workflow', () => { const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) + expect(nativeCommandSteps.some(step => ( + step.run.includes('packages/subprocess/win32-process/verify/abi-probe.cpp') + && step.run.includes('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') + ))).toBe(true) expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') // wine-apt-cache: master-only, seeds the Wine apt cache. From 5490a5e070899fb22a00964216697610069ee78f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:09:38 +0800 Subject: [PATCH 06/23] ci(windows): run ABI probes with MSVC --- .github/workflows/ci.yml | 19 +++++++++++-------- scripts/ci-workflow.spec.ts | 2 ++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e945879e1b..88ec032d62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -495,14 +495,17 @@ jobs: New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null $processProbe = Join-Path $probeRoot 'win32-process.exe' $sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' - g++ -std=c++20 -municode -O2 -o $processProbe packages/subprocess/win32-process/verify/abi-probe.cpp - if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe compilation failed' } - & $processProbe - if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe failed' } - g++ -std=c++20 -municode -O2 -o $sandboxProbe packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp -ladvapi32 - if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe compilation failed' } - & $sandboxProbe - if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe failed' } + $vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } + $vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() + if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } + $vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' + if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } + $processSource = Join-Path $PWD 'packages/subprocess/win32-process/verify/abi-probe.cpp' + $sandboxSource = Join-Path $PWD 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' + $probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" + & cmd.exe /d /s /c $probeCommand + if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } - name: Run complete native Windows gate inventory shell: pwsh diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index df60db0626..3f0b9047ef 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -81,6 +81,8 @@ describe('CI workflow', () => { expect(nativeCommandSteps.some(step => ( step.run.includes('packages/subprocess/win32-process/verify/abi-probe.cpp') && step.run.includes('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') + && step.run.includes('vswhere.exe') + && step.run.includes('vcvars64.bat') ))).toBe(true) expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') From 4f381b83c969c57c6f03c58676c620232fb04628 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:23:43 +0800 Subject: [PATCH 07/23] refactor(win32-process): remove redundant suspension --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 4 +- ...8-19-shared-win32-process-primitives.zh.md | 4 +- .../sandbox/sandbox-windows-acl/src/spawn.ts | 2 +- .../tests/index-failure-paths.spec.ts | 3 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 2 +- packages/subprocess/win32-process/src/abi.ts | 2 - packages/subprocess/win32-process/src/ffi.ts | 2 - .../subprocess/win32-process/src/process.ts | 15 ++---- .../tests/process-allocation-failure.spec.ts | 1 - .../tests/process-failure-paths.spec.ts | 52 ------------------- .../win32-process/tests/process.spec.ts | 7 +-- .../win32-process/verify/abi-probe.cpp | 2 - 15 files changed, 18 insertions(+), 88 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 1eda0cef7b..02bb27c70f 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 58bbd5a2ae44caf85dfca144d99efabb063243e2 -2026-08-19-shared-win32-process-primitives.zh.md: 5c4e63979412fbb617094ad1745f4a8318b49237 +2026-08-19-shared-win32-process-primitives.md: 8e5878ff23a49d9f0fbc4e62b9e3e6bccf5fe4ed +2026-08-19-shared-win32-process-primitives.zh.md: 90ca5b21227136febafa946d20acbe59630436fd diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 58bbd5a2ae..8e5878ff23 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,13 +14,13 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so a successfully created suspended child is already Job-owned before resume; attribute, creation, or resume failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic suspended Job attachment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic Job attachment during creation, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 5c4e639794..90ca5b2122 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,13 +14,13 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此成功创建的 suspended child 在 resume 前已经归属 Job;attribute、创建或 resume 失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的原子 suspended Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、创建时的原子 Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index a36b0253c4..3336a309f1 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -39,7 +39,7 @@ export function spawnSandboxed( * @param api - ACL/token binding table. * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns process and Job handles after assignment and resume. + * @returns process and Job handles after atomic attachment during creation. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 4f0957ea65..a8834a8c61 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -154,7 +154,6 @@ function happyStubs(): HappyStubs { }) const updateProcThreadAttribute = vi.fn(() => 1) const deleteProcThreadAttributeList = vi.fn() - const resumeThread = vi.fn(() => 0) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) const closeHandle = vi.fn(() => 1) @@ -169,7 +168,7 @@ function happyStubs(): HappyStubs { setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute, - deleteProcThreadAttributeList, resumeThread, getStdHandle, + deleteProcThreadAttributeList, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 909e34fecb..4fdca725f6 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md -README.md: 53064791de5375cd05008509db4f9d27beec3dc3 -README.zh.md: f5c6bc8334908241b5e6a2332f79e3d3808f6469 +README.md: 601c095ea48fbf46f151ee69ad2e485881dcb4af +README.zh.md: 53a55ef58a404c52ded1d417ba4fa516a3092783 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 53064791de..601c095ea4 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -9,7 +9,7 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the - **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. -- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, attaches that Job through `STARTUPINFOEXW`, creates the restricted child suspended and already Job-owned, restores the parent handle flags, and resumes the child. Attribute setup, creation, or resume failure closes every owned resource; no successful process creation can leave an unowned suspended child. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, and attaches that Job through `STARTUPINFOEXW` while creating the restricted child. The child is Job-owned before any user code can run; attribute setup or creation failure closes every owned resource, and no successful process creation can leave an unowned child. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index f5c6bc8334..53a55ef58a 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -9,7 +9,7 @@ - **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 -- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,通过 `STARTUPINFOEXW` 附加该 Job,以 suspended 且已经归属 Job 的状态创建 restricted child,恢复父进程句柄标志,再 resume child。attribute 设置、创建或 resume 失败都会关闭全部已拥有资源;成功创建进程后不会留下无 owner 的 suspended child。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,并在创建 restricted child 时通过 `STARTUPINFOEXW` 附加该 Job。child 会在任何用户代码运行前归属 Job;attribute 设置或创建失败都会关闭全部已拥有资源,成功创建进程后不会留下无 owner 的 child。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 168879bf65..9409e9025b 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,8 +6,6 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** CreateProcess flag that prevents user code from running before resume. */ -export const CREATE_SUSPENDED = 0x4 /** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */ export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 /** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b22f096bfc..4abea75c5e 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -108,7 +108,6 @@ export interface Win32ProcessBindings { ): number waitForSingleObject(handle: NativePtr, milliseconds: number): number getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number - resumeThread(thread: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number terminateProcess(process: NativePtr, exitCode: number): number @@ -269,7 +268,6 @@ function bindings(): Win32ProcessBindings { ]), waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), - resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index ff5d7a9386..7404ccb4ba 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -78,7 +78,7 @@ export interface SpawnedPipedProcess { stderrRead: NativePtr } -/** Suspended-created child atomically attached to one caller-owned kill-on-close Job. */ +/** Child atomically attached to one caller-owned kill-on-close Job during creation. */ export interface SpawnedJobProcess { /** Direct child process id. */ pid: number @@ -311,10 +311,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { } /** - * Spawn suspended and atomically attached to a kill-on-close Job, then resume. + * Spawn atomically attached to a kill-on-close Job. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. - * @returns caller-owned process and Job handles after successful resume. + * @returns caller-owned process and Job handles after successful creation. */ export function spawnInheritedJobProcess( api: Win32ProcessBindings, @@ -358,7 +358,7 @@ export function spawnInheritedJobProcess( api, options, buildCommandLine(options.command, options.args), - abi.CREATE_SUSPENDED | abi.EXTENDED_STARTUPINFO_PRESENT, + abi.EXTENDED_STARTUPINFO_PRESENT, startupInfo.pointer, processInfo, ) @@ -395,13 +395,6 @@ export function spawnInheritedJobProcess( closeBestEffort(api, info.hProcess) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } - if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { - const win32Code = api.getLastError() - closeBestEffort(api, info.hThread) - closeBestEffort(api, info.hProcess) - api.closeHandle(job) - throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) - } closeBestEffort(api, info.hThread) return { pid: info.dwProcessId, process: info.hProcess, job } } diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts index b850292b6a..f9e115e8d0 100644 --- a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -75,7 +75,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { }) return 1 }), - resumeThread: vi.fn(() => 1), closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 917974a868..93a501c197 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -61,40 +61,6 @@ function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeH return { api, closed, closeHandle } } -/** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */ -function resumeFailureApi(): { - api: Win32ProcessBindings - closed: bigint[] - closeHandle: ReturnType -} { - const closed: bigint[] = [] - let std = 50n - const closeHandle = vi.fn((handle: NativePtr) => { - closed.push(handle) - return 1 - }) - const resumeThread = vi.fn(() => 0xFFFFFFFF) - 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 - }), - ...jobAttributeStubs(), - resumeThread, - getLastError: vi.fn(() => 5), - closeHandle, - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - return { api, closed, closeHandle } -} - describe('spawn failure paths close their handles', () => { // A dummy token value; the stubbed spawn never reads it. const token = 1n as NativePtr @@ -114,23 +80,6 @@ describe('spawn failure paths close their handles', () => { expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n]) }) - it('closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { - const { api, closed, closeHandle } = resumeFailureApi() - let caught: unknown - try { - spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) - } catch (error) { - caught = error - } - expect(caught).toBeInstanceOf(Win32Error) - expect((caught as Win32Error).api).toBe('ResumeThread') - expect((caught as Win32Error).win32Code).toBe(5) - // thread, process, job — closing the job triggers kill-on-close so the - // suspended child dies instead of hanging until this process exits. - expect(closeHandle).toHaveBeenCalledTimes(3) - expect(closed).toEqual([201n, 200n, 100n]) - }) - }) /** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */ @@ -257,7 +206,6 @@ describe('spawnInheritedJobProcess failure paths', () => { return 1 }), ...jobAttributeStubs(), - resumeThread: vi.fn(() => 0), getLastError: vi.fn(() => 5), closeHandle, formatMessageW: vi.fn(() => 0), diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index b729c5d651..c734d13013 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -8,7 +8,6 @@ import { spawnPipedProcess, } from '../src/index.ts' import { - CREATE_SUSPENDED, EXTENDED_STARTUPINFO_PRESENT, POINTER_SIZE, PROC_THREAD_ATTRIBUTE_JOB_LIST, @@ -68,7 +67,6 @@ function inheritedApi(overrides: Partial = {}): { initializeProcThreadAttributeList, updateProcThreadAttribute, deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }), - resumeThread: vi.fn(() => { events.push('resume'); return 1 }), terminateProcess: vi.fn(() => 1), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), getLastError: vi.fn(() => 5), @@ -89,7 +87,7 @@ function inheritedApi(overrides: Partial = {}): { describe('spawnInheritedJobProcess', () => { const token = 70n as NativePtr - it('attaches a restricted suspended child to the Job inside CreateProcessAsUserW', () => { + it('attaches a restricted child to the Job inside CreateProcessAsUserW', () => { const { api, events, @@ -107,7 +105,6 @@ describe('spawnInheritedJobProcess', () => { expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create')) expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create')) - expect(events.indexOf('resume')).toBeGreaterThan(events.indexOf('create')) expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything()) expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything()) expect(updateProcThreadAttribute).toHaveBeenCalledWith( @@ -127,7 +124,7 @@ describe('spawnInheritedJobProcess', () => { null, null, 1, - CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT, + EXTENDED_STARTUPINFO_PRESENT, null, 'C:\\work', expect.anything(), diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 0b0911ea93..50452bd514 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -19,7 +19,6 @@ int wmain() P(offsetof(PROCESS_INFORMATION, hProcess)); P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); - P(CREATE_SUSPENDED); P(EXTENDED_STARTUPINFO_PRESENT); P(PROC_THREAD_ATTRIBUTE_JOB_LIST); P(STARTF_USESTDHANDLES); @@ -42,7 +41,6 @@ int wmain() static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size"); static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(CREATE_SUSPENDED == 0x4, "create suspended"); static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag"); static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); From 4605124732ba004f6fafc42a8489c3b814a8d309 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 05:49:56 +0800 Subject: [PATCH 08/23] ci(windows): exercise ABI probes on failover standby --- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +-- .../process/2026-07-26-ci-failover-runbook.md | 2 +- .../2026-07-26-ci-failover-runbook.zh.md | 2 +- .github/workflows/ci.yml | 21 ++++------------ scripts/ci-workflow.spec.ts | 21 ++++++++++------ scripts/verify-win32-abi.ps1 | 25 +++++++++++++++++++ 6 files changed, 47 insertions(+), 28 deletions(-) create mode 100644 scripts/verify-win32-abi.ps1 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index f8cdf8e924..55592adfb6 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc -2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2 +2026-07-26-ci-failover-runbook.md: c4d1677d8f8f632ae31cf5bcfbbd5386c9932919 +2026-07-26-ci-failover-runbook.zh.md: bce9054e051d8c919b038337922174e33ad60f9c diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index e8a1d1dc33..c4d1677d8f 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -24,7 +24,7 @@ The decision belongs at workflow level because cancellation applies to the whole #### Windows pool -`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. +`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, Visual Studio C++ Build Tools with the x64 MSVC toolchain and Windows SDK, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: before the complete aggregate, that lane compiles and runs the same two Win32 header ABI probes as `windows-native`, so a green standby verifies both the compiler prerequisite and `check:ci:windows-complete` end-to-end. ### Switch (any repository writer, ~1 minute, no merge) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 8f92b7b60c..bce9054e05 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -24,7 +24,7 @@ Status: implemented #### Windows 池 -`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池能端到端执行 `check:ci:windows-complete`。 +`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7、带 x64 MSVC 工具链与 Windows SDK 的 Visual Studio C++ Build Tools,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:该通道会在完整聚合前编译并运行与 `windows-native` 相同的两份 Win32 header ABI probe,因此绿色热备会同时验证编译器前置条件与 `check:ci:windows-complete` 端到端流程。 ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88ec032d62..959aad6b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -490,22 +490,7 @@ jobs: - name: Compile and run Win32 header ABI probes shell: pwsh - run: | - $probeRoot = Join-Path $env:RUNNER_TEMP 'dsh-win32-abi-probes' - New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null - $processProbe = Join-Path $probeRoot 'win32-process.exe' - $sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' - $vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' - if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } - $vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() - if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } - $vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' - if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } - $processSource = Join-Path $PWD 'packages/subprocess/win32-process/verify/abi-probe.cpp' - $sandboxSource = Join-Path $PWD 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' - $probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" - & cmd.exe /d /s /c $probeCommand - if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } + run: ./scripts/verify-win32-abi.ps1 - name: Run complete native Windows gate inventory shell: pwsh @@ -710,6 +695,10 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile + - name: Compile and run Win32 header ABI probes + shell: pwsh + run: ./scripts/verify-win32-abi.ps1 + - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 3f0b9047ef..0b563cb469 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -49,8 +49,8 @@ describe('CI workflow', () => { const node24Coverage = workflow.jobs['node-24-coverage'] const node24Consumers = workflow.jobs['node-24-consumers'] const aggregate = workflow.jobs['all-checks-passed'] - if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { - throw new TypeError('Windows job must define steps and the aggregate must define needs') + if (!Array.isArray(windows.steps) || !Array.isArray(serialWindows.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows jobs must define steps and the aggregate must define needs') } const commandSteps = windows.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' @@ -78,12 +78,7 @@ describe('CI workflow', () => { const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) - expect(nativeCommandSteps.some(step => ( - step.run.includes('packages/subprocess/win32-process/verify/abi-probe.cpp') - && step.run.includes('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') - && step.run.includes('vswhere.exe') - && step.run.includes('vcvars64.bat') - ))).toBe(true) + expect(nativeCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') // wine-apt-cache: master-only, seeds the Wine apt cache. @@ -94,6 +89,16 @@ describe('CI workflow', () => { expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') + const serialWindowsCommandSteps = serialWindows.steps.filter((step): step is Record & { run: string } => ( + isRecord(step) && typeof step.run === 'string' + )) + expect(serialWindowsCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') + expect(serialWindowsCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') + const abiProbeScript = readFileSync(resolve(root, 'scripts/verify-win32-abi.ps1'), 'utf8') + expect(abiProbeScript).toContain('vswhere.exe') + expect(abiProbeScript).toContain('vcvars64.bat') + expect(abiProbeScript).toContain('packages/subprocess/win32-process/verify/abi-probe.cpp') + expect(abiProbeScript).toContain('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') diff --git a/scripts/verify-win32-abi.ps1 b/scripts/verify-win32-abi.ps1 new file mode 100644 index 0000000000..c0125c9eab --- /dev/null +++ b/scripts/verify-win32-abi.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } +$probeRoot = Join-Path $temporaryRoot 'dsh-win32-abi-probes' +New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null + +$vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } +$vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() +if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } +$vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' +if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } + +$processProbe = Join-Path $probeRoot 'win32-process.exe' +$processObject = Join-Path $probeRoot 'win32-process.obj' +$processSource = Join-Path $repoRoot 'packages/subprocess/win32-process/verify/abi-probe.cpp' +$sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' +$sandboxObject = Join-Path $probeRoot 'sandbox-windows-acl.obj' +$sandboxSource = Join-Path $repoRoot 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' + +$probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$processObject`" /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$sandboxObject`" /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" +& cmd.exe /d /s /c $probeCommand +if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } From 5375142827147d1c08c1dafca05948a227e5521c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 06:35:50 +0800 Subject: [PATCH 09/23] fix(sandbox): contain drain failure settlement --- ...-shared-win32-process-primitives.i18n.yaml | 4 +-- ...6-08-19-shared-win32-process-primitives.md | 2 +- ...8-19-shared-win32-process-primitives.zh.md | 2 +- AGENTS.md | 2 +- packages/README.i18n.yaml | 4 +-- packages/README.md | 2 +- packages/README.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/index.ts | 29 +++++++++++++------ .../sandbox/sandbox-windows-acl/src/token.ts | 5 ++-- .../sandbox-windows-acl/src/win32-abi.ts | 6 +++- .../sandbox-windows-acl/tests/ffi.spec.ts | 9 +++--- .../tests/index-failure-paths.spec.ts | 18 ++++++++++++ .../subprocess/win32-process/README.i18n.yaml | 4 +-- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 4 +-- .../subprocess/win32-process/src/index.ts | 1 - .../subprocess/win32-process/src/process.ts | 12 +++++++- .../win32-process/tests/quote.spec.ts | 3 +- 18 files changed, 76 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 02bb27c70f..0159e844a6 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 8e5878ff23a49d9f0fbc4e62b9e3e6bccf5fe4ed -2026-08-19-shared-win32-process-primitives.zh.md: 90ca5b21227136febafa946d20acbe59630436fd +2026-08-19-shared-win32-process-primitives.md: 60e9b5b76154a4833979b014dffb4015cb0c5c36 +2026-08-19-shared-win32-process-primitives.zh.md: 83bb71ddbe182084097c54325172a3f923b33138 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 8e5878ff23..60e9b5b761 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait, or closes the process handle and reports the termination failure without blocking. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 90ca5b2122..83bb71ddbe 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child,若终止本身失败则关闭 process handle 并报告该失败,不阻塞事件循环。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 diff --git a/AGENTS.md b/AGENTS.md index 99b31077db..f50a5b24d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM capability: Service Definition/Consumer + DeepSeek providers e2b/ E2B POC: sandbox + FS/subprocess adapters shell/ bash capability: Service Definition + local/pwsh providers + shell Consumers - subprocess/ subprocess capability + local process-tree provider + subprocess/ subprocess capability + local process-tree provider + shared Win32 library terminal/ persistent sessions fs/ filesystem capability + policy lsp/ language-server capability diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index ee425f39d7..ba5767f75b 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: a410d7148d14503a61edb9c4848b521552050ca4 -README.zh.md: 780d1356f2095c7dcc526e5cdac8994267e4a460 +README.md: defbd29942f7733919a9c65a1f8174148919b034 +README.zh.md: f47584209d4d54eedec48f5f5c4e90479cf5c7d9 diff --git a/packages/README.md b/packages/README.md index a410d7148d..defbd29942 100644 --- a/packages/README.md +++ b/packages/README.md @@ -19,7 +19,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`identity/`](identity/README.md) | Shared anonymous identity | Product — stable API | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API | | [`e2b/`](e2b/README.md) | E2B providers | POC | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable API | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local process-tree provider, and shared Win32 process library | Product — stable API | | [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API | | [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index 780d1356f2..f47584209d 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -19,7 +19,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`identity/`](identity/README.md) | 共享匿名身份 | 产品:稳定 API | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API | | [`e2b/`](e2b/README.md) | E2B 提供方 | POC | -| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | 产品:稳定 API | +| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition、本地进程树提供方与共享 Win32 进程库 | 产品:稳定 API | | [`shell/`](shell/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API | | [`terminal/`](terminal/README.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API | diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 5a46eb1423..231d1cb0c3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -55,7 +55,7 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' -export { quoteArg, Win32Error } from '@deepseek-ai/dsh-win32-process' +export { Win32Error } from '@deepseek-ai/dsh-win32-process' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { @@ -382,10 +382,11 @@ export class AclSandbox { const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) const stdout = drainPipe(api, native.stdoutRead) const stderr = drainPipe(api, native.stderrRead) - // waitForExit is deliberately NOT started here: WaitForSingleObject blocks - // the thread and would starve the drains while the child is still running - // (pipe-buffer deadlock). The drains resolve only after the child closed - // its pipe ends — by then the wait returns immediately. + // WaitForSingleObject blocks the thread, so settlement starts it only after + // both drains settle. Successful drains mean the child closed its pipe ends + // and the wait returns immediately. A failed drain terminates the child + // before waiting, so a native pipe failure cannot pin the event loop on a + // still-running command. let settlement: Promise | undefined return { pid: native.pid, @@ -394,10 +395,20 @@ export class AclSandbox { const failures = drains.flatMap(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : []) let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) + if (failures.length > 0 && api.terminateProcess(native.process, 1) === 0) { + const terminationCode = api.getLastError() + try { + closeHandleChecked(api, native.process, 'piped child after drain failure') + } catch (error) { + failures.push(error) + } + failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) + } else { + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) + } } if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index abd0c73617..96aa53b277 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -180,8 +180,9 @@ export interface RestrictingSidSet { * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's * Public tree grants write to INTERACTIVE, so removing it closes that - * escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts - * for the verified failure modes. FAILS CLOSED: any failure throws — never + * escape. S-1-2-1 (console logon) is intentionally absent: the package + * README's "Console isolation is unavailable" entry records the verified + * failure modes. FAILS CLOSED: any failure throws — never * spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 019f894a6c..478fc71e1d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -20,7 +20,11 @@ export const FILE_GENERIC_WRITE = 0x00120116 export const DELETE = 0x00010000 /** Delete or rename a directory child. */ export const FILE_DELETE_CHILD = 0x0040 -/** Capability-SID access mask granting write, delete, and child deletion. */ +/** + * Capability-SID access mask granting write, delete, and child deletion. + * WRITE_DAC and WRITE_OWNER stay excluded so a confined child cannot rewrite + * DACLs or take ownership to escape the allowlist. + */ export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE /** Full access used in the restricted token default DACL. */ export const FILE_ALL_ACCESS = 0x1F01FF diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index ebd3ba1b35..761598a073 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { Win32Error, quoteArg } from '../src/index.ts' +import { Win32Error } from '../src/index.ts' import { allocBytes, decodePtrAt, getTempPath, isInvalidHandle, sameSidAt, @@ -17,7 +17,7 @@ import { import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' -/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */ +/** A stub whose formatMessageW supplies text to the GetTempPath failure path. */ function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType } { const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => { const text = 'access denied' @@ -75,10 +75,9 @@ describe('getTempPath', () => { }) }) -describe('public compatibility exports', () => { - it('keeps the sandbox Win32 error and quoting API', () => { +describe('public error export', () => { + it('keeps the sandbox Win32 error type', () => { expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error) - expect(quoteArg('a b')).toBe('"a b"') }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index a8834a8c61..a75f3546d3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -448,6 +448,8 @@ describe('AclSandbox spawn', () => { it('pipe spawn still closes the process after a drain failure', async () => { const { api } = state.stubs as HappyStubs api.getLastError = vi.fn(() => 5) + const terminateProcess = vi.fn(() => 1) + api.terminateProcess = terminateProcess const waitForSingleObject = vi.fn(() => 0) api.waitForSingleObject = waitForSingleObject const workspace = scratch() @@ -460,8 +462,24 @@ describe('AclSandbox spawn', () => { expect.objectContaining({ api: 'PeekNamedPipe' }), ], }) + expect(terminateProcess).toHaveBeenCalledOnce() expect(waitForSingleObject).toHaveBeenCalledOnce() }) + + it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { + const { api, closeHandle } = state.stubs as HappyStubs + api.getLastError = vi.fn(() => 5) + api.terminateProcess = vi.fn(() => 0) + const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') }) + api.waitForSingleObject = waitForSingleObject + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-3', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toBeInstanceOf(AggregateError) + expect(waitForSingleObject).not.toHaveBeenCalled() + expect(closeHandle).toHaveBeenCalled() + }) }) describe('AclSandbox dispose', () => { diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 4fdca725f6..1f15b27c96 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md -README.md: 601c095ea48fbf46f151ee69ad2e485881dcb4af -README.zh.md: 53a55ef58a404c52ded1d417ba4fa516a3092783 +README.md: c3bc0d74c3c5a375d289e9a3e4037648341f4906 +README.zh.md: 6763f2b24633d7dd250eecdafe5e1724ffa29fa6 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 601c095ea4..c3bc0d74c3 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -34,6 +34,6 @@ The package contributes no stable request prefix, so it does not invalidate mode - **Windows-only native loading** — importing the generic types is portable, but resolving the binding table loads Windows DLLs and fails on other hosts. Cross-platform tests inject a binding table instead of loading native APIs. - **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. -- **Inherited environment only** — process creation passes a null environment block. Callers that need environment changes must establish them before invoking the primitive or use their own runner process. +- **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process. - **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. - **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 53a55ef58a..6763f2b246 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -26,7 +26,7 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 没有直接影响。消费方决定进程输出是否进入工具结果或后续模型请求。 -#### KV Cache effect +#### KV Cache 影响 本包不贡献稳定请求前缀,因此不会使模型 KV Cache 失效。 @@ -34,6 +34,6 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 - **仅在 Windows 原生加载** — 导入通用类型可跨平台进行,但解析绑定表会加载 Windows DLL,并在其他宿主失败。跨平台测试注入绑定表,不加载原生 API。 - **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 -- **只继承环境** — 进程创建传入空环境块。需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 +- **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW` 以 `ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 - **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 - **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index e7693db495..fa7f2dd992 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -19,7 +19,6 @@ export type { export { closeHandleChecked, drainPipe, - quoteArg, spawnInheritedJobProcess, spawnPipedProcess, waitForProcessExit, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 7404ccb4ba..c9fb888515 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -142,6 +142,9 @@ function createRestrictedProcess( startupInfo: NativePtr, processInfo: NativePtr, ): number { + // The sandbox mutates its process environment before this call. Passing an + // explicit block through Koffi makes CreateProcessAsUserW reject the request + // with ERROR_INVALID_PARAMETER, so lpEnvironment remains NULL. return api.createProcessAsUserW( options.token, null, @@ -315,6 +318,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. * @returns caller-owned process and Job handles after successful creation. + * @remarks Node clears stdio handle inheritability at startup through + * uv_disable_stdio_inheritance. This operation temporarily restores the bits + * required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort: + * failure must not replace the already-created child's outcome. */ export function spawnInheritedJobProcess( api: Win32ProcessBindings, @@ -371,7 +378,10 @@ export function spawnInheritedJobProcess( api.closeHandle(job) throw error } finally { - for (const handle of enabled) api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + for (const handle of enabled) { + // The runner spawns nothing else; cleanup failure must not mask the child. + api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } } if (created === 0) { freeNative(processInfo) diff --git a/packages/subprocess/win32-process/tests/quote.spec.ts b/packages/subprocess/win32-process/tests/quote.spec.ts index f93d721cc3..63dcc7bc77 100644 --- a/packages/subprocess/win32-process/tests/quote.spec.ts +++ b/packages/subprocess/win32-process/tests/quote.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' -import { quoteArg } from '../src/index.ts' -import { buildCommandLine } from '../src/process.ts' +import { buildCommandLine, quoteArg } from '../src/process.ts' const isWin32 = process.platform === 'win32' From 33e90e991943374577d4a35883142b203dbf3974 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 06:58:54 +0800 Subject: [PATCH 10/23] fix(sandbox): terminate on first drain failure --- .../sandbox/sandbox-windows-acl/src/index.ts | 41 ++++++++++------ .../tests/index-failure-paths.spec.ts | 49 +++++++++++++++++++ 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 231d1cb0c3..ecd7eb4cce 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -391,24 +391,35 @@ export class AclSandbox { return { pid: native.pid, wait: () => (settlement ??= (async () => { - const drains = await Promise.allSettled([stdout, stderr]) + let drains: PromiseSettledResult[] + try { + const [stdoutBuffer, stderrBuffer] = await Promise.all([stdout, stderr]) + drains = [ + { status: 'fulfilled', value: stdoutBuffer }, + { status: 'fulfilled', value: stderrBuffer }, + ] + } catch (firstDrainFailure) { + if (api.terminateProcess(native.process, 1) === 0) { + const failures: unknown[] = [firstDrainFailure] + const terminationCode = api.getLastError() + try { + closeHandleChecked(api, native.process, 'piped child after drain failure') + } catch (error) { + failures.push(error) + } + failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) + void Promise.allSettled([stdout, stderr]) + throw new AggregateError(failures, 'piped child settlement failed') + } + drains = await Promise.allSettled([stdout, stderr]) + } const failures = drains.flatMap(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : []) let exitCode = 0 - if (failures.length > 0 && api.terminateProcess(native.process, 1) === 0) { - const terminationCode = api.getLastError() - try { - closeHandleChecked(api, native.process, 'piped child after drain failure') - } catch (error) { - failures.push(error) - } - failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) - } else { - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } + try { + exitCode = waitForExit(api, native.process) + } catch (error) { + failures.push(error) } if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index a75f3546d3..78032e2c32 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -466,6 +466,38 @@ describe('AclSandbox spawn', () => { expect(waitForSingleObject).toHaveBeenCalledOnce() }) + it('pipe spawn terminates promptly when one drain fails and the sibling remains open', async () => { + const { api } = state.stubs as HappyStubs + let peekCount = 0 + let terminated = false + let lastError = 5 + api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { + peekCount += 1 + if (peekCount === 1) return 0 + if (terminated) { + lastError = ERROR_BROKEN_PIPE + return 0 + } + koffi.encode(totalAvail, 'uint32', 0) + return 1 + }) + api.getLastError = vi.fn(() => lastError) + const terminateProcess = vi.fn(() => { + terminated = true + return 1 + }) + api.terminateProcess = terminateProcess + const waitForSingleObject = vi.fn(() => 0) + api.waitForSingleObject = waitForSingleObject + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2-1', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + await expect(child.wait()).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + expect(terminateProcess).toHaveBeenCalledOnce() + expect(waitForSingleObject).toHaveBeenCalledOnce() + }) + it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { const { api, closeHandle } = state.stubs as HappyStubs api.getLastError = vi.fn(() => 5) @@ -480,6 +512,23 @@ describe('AclSandbox spawn', () => { expect(waitForSingleObject).not.toHaveBeenCalled() expect(closeHandle).toHaveBeenCalled() }) + + it('pipe spawn aggregates process-handle closure failure after termination failure', async () => { + const { api } = state.stubs as HappyStubs + api.getLastError = vi.fn(() => 5) + api.terminateProcess = vi.fn(() => 0) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-4', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe' }) + api.closeHandle = vi.fn(() => 0) + await expect(child.wait()).rejects.toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ api: 'CloseHandle' }), + expect.objectContaining({ api: 'TerminateProcess' }), + ]), + }) + }) }) describe('AclSandbox dispose', () => { From f9a264c76e7173548848889565b8fa5376263e11 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 07:15:00 +0800 Subject: [PATCH 11/23] test(sandbox): keep aggregate failure assertion typed --- .../tests/index-failure-paths.spec.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 78032e2c32..bbcddefb22 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -522,12 +522,13 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) api.closeHandle = vi.fn(() => 0) - await expect(child.wait()).rejects.toMatchObject({ - errors: expect.arrayContaining([ - expect.objectContaining({ api: 'CloseHandle' }), - expect.objectContaining({ api: 'TerminateProcess' }), - ]), - }) + const failure = await child.wait().catch((error: unknown): unknown => error) + expect(failure).toBeInstanceOf(AggregateError) + if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') + const apis = (failure.errors as unknown[]) + .filter((error): error is Win32Error => error instanceof Win32Error) + .map(error => error.api) + expect(apis).toEqual(expect.arrayContaining(['CloseHandle', 'TerminateProcess'])) }) }) From 9241ac22af33877dd55d4c506b0df4de1e3dc333 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 07:21:00 +0800 Subject: [PATCH 12/23] test(sandbox): preserve rejection evidence --- .../sandbox-windows-acl/tests/index-failure-paths.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index bbcddefb22..5db35403ec 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -522,7 +522,9 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) api.closeHandle = vi.fn(() => 0) - const failure = await child.wait().catch((error: unknown): unknown => error) + const settlement = child.wait() + await expect(settlement).rejects.toBeInstanceOf(AggregateError) + const failure = await settlement.catch((error: unknown): unknown => error) expect(failure).toBeInstanceOf(AggregateError) if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') const apis = (failure.errors as unknown[]) From 8505d61f6965e95f330f09828bb420479891d9b3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 07:24:36 +0800 Subject: [PATCH 13/23] test(sandbox): remove duplicate failure assertion --- .../sandbox-windows-acl/tests/index-failure-paths.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 5db35403ec..a4844e598f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -525,7 +525,6 @@ describe('AclSandbox spawn', () => { const settlement = child.wait() await expect(settlement).rejects.toBeInstanceOf(AggregateError) const failure = await settlement.catch((error: unknown): unknown => error) - expect(failure).toBeInstanceOf(AggregateError) if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') const apis = (failure.errors as unknown[]) .filter((error): error is Win32Error => error instanceof Win32Error) From 60587b4901c779fb00931545ec6c326ff07c6fc4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 08:06:38 +0800 Subject: [PATCH 14/23] fix(sandbox): cancel sibling drain on termination failure --- ...9-shared-win32-process-primitives.i18n.yaml | 4 ++-- ...26-08-19-shared-win32-process-primitives.md | 2 +- ...08-19-shared-win32-process-primitives.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 ++ .../sandbox/sandbox-windows-acl/src/index.ts | 8 +++++--- .../sandbox/sandbox-windows-acl/src/token.ts | 6 +++--- .../tests/index-failure-paths.spec.ts | 10 ++++++++++ .../subprocess/win32-process/README.i18n.yaml | 4 ++-- packages/subprocess/win32-process/README.md | 2 +- packages/subprocess/win32-process/README.zh.md | 2 +- .../subprocess/win32-process/src/errors.ts | 2 +- .../subprocess/win32-process/src/process.ts | 9 ++++++++- .../win32-process/tests/process.spec.ts | 18 ++++++++++++++++++ .../subprocess/win32-process/tsconfig.json | 3 +++ 14 files changed, 58 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 0159e844a6..5e1aeb4d0f 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 60e9b5b76154a4833979b014dffb4015cb0c5c36 -2026-08-19-shared-win32-process-primitives.zh.md: 83bb71ddbe182084097c54325172a3f923b33138 +2026-08-19-shared-win32-process-primitives.md: ae7720273333f675b9fb4178405bedbc32982a59 +2026-08-19-shared-win32-process-primitives.zh.md: 2d8b9d3421fa4eb4100f4f016018301e21de1eef diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 60e9b5b761..ae77202733 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait, or closes the process handle and reports the termination failure without blocking. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait. When termination itself fails, settlement cancels and joins the sibling drain before closing the process handle and reporting the failure, so rejection leaves no polling timer alive. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 83bb71ddbe..2d8b9d3421 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child,若终止本身失败则关闭 process handle 并报告该失败,不阻塞事件循环。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child。若终止本身失败,settlement 会先取消并等待 sibling drain 结束,再关闭 process handle 并报告失败,因此 rejection 不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 18e262a66b..983f5953f8 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -139,6 +139,8 @@ export function allocBytes(length: number): NativePtr { /** * Allocate one zeroed x64 OVERLAPPED record. * @returns allocated pointer. + * @remarks Koffi 3.1.1 crashes when LockFileEx or UnlockFileEx receives NULL; + * a zeroed OVERLAPPED is equivalent for the synchronous lock-file handle. */ export function allocOverlapped(): NativePtr { return allocBytes(32) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index ecd7eb4cce..2dc33306af 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -380,8 +380,9 @@ export class AclSandbox { } const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) - const stdout = drainPipe(api, native.stdoutRead) - const stderr = drainPipe(api, native.stderrRead) + const drainAbort = new AbortController() + const stdout = drainPipe(api, native.stdoutRead, drainAbort.signal) + const stderr = drainPipe(api, native.stderrRead, drainAbort.signal) // WaitForSingleObject blocks the thread, so settlement starts it only after // both drains settle. Successful drains mean the child closed its pipe ends // and the wait returns immediately. A failed drain terminates the child @@ -402,13 +403,14 @@ export class AclSandbox { if (api.terminateProcess(native.process, 1) === 0) { const failures: unknown[] = [firstDrainFailure] const terminationCode = api.getLastError() + drainAbort.abort() + await Promise.allSettled([stdout, stderr]) try { closeHandleChecked(api, native.process, 'piped child after drain failure') } catch (error) { failures.push(error) } failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) - void Promise.allSettled([stdout, stderr]) throw new AggregateError(failures, 'piped child settlement failed') } drains = await Promise.allSettled([stdout, stderr]) diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 96aa53b277..f1f2befbdd 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -180,9 +180,9 @@ export interface RestrictingSidSet { * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's * Public tree grants write to INTERACTIVE, so removing it closes that - * escape. S-1-2-1 (console logon) is intentionally absent: the package - * README's "Console isolation is unavailable" entry records the verified - * failure modes. FAILS CLOSED: any failure throws — never + * escape. S-1-2-1 (console logon) is intentionally absent: the package + * README's "Console isolation is unavailable" entry records the verified + * failure modes. FAILS CLOSED: any failure throws — never * spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index a4844e598f..41ca5463f1 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -500,6 +500,13 @@ describe('AclSandbox spawn', () => { it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { const { api, closeHandle } = state.stubs as HappyStubs + let peekCount = 0 + api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { + peekCount += 1 + if (peekCount === 1) return 0 + koffi.encode(totalAvail, 'uint32', 0) + return 1 + }) api.getLastError = vi.fn(() => 5) api.terminateProcess = vi.fn(() => 0) const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') }) @@ -509,6 +516,9 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) await expect(child.wait()).rejects.toBeInstanceOf(AggregateError) + const settledPeekCount = peekCount + await new Promise(resolve => setTimeout(resolve, 5)) + expect(peekCount).toBe(settledPeekCount) expect(waitForSingleObject).not.toHaveBeenCalled() expect(closeHandle).toHaveBeenCalled() }) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 1f15b27c96..24f7b1f607 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md -README.md: c3bc0d74c3c5a375d289e9a3e4037648341f4906 -README.zh.md: 6763f2b24633d7dd250eecdafe5e1724ffa29fa6 +README.md: 3e82c10b7894b15d970b794429c69c6923632bb9 +README.zh.md: b1afc1a7222189329cbd84d9729fbafc3ecd3acb diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index c3bc0d74c3..3e82c10b78 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,7 +10,7 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, and attaches that Job through `STARTUPINFOEXW` while creating the restricted child. The child is Job-owned before any user code can run; attribute setup or creation failure closes every owned resource, and no successful process creation can leave an unowned child. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining, accepts cancellation that stops polling, and frees its allocation before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 6763f2b246..b1afc1a722 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,7 +10,7 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,并在创建 restricted child 时通过 `STARTUPINFOEXW` 附加该 Job。child 会在任何用户代码运行前归属 Job;attribute 设置或创建失败都会关闭全部已拥有资源,成功创建进程后不会留下无 owner 的 child。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,接受停止轮询的取消信号,并在关闭管道读取句柄前释放原生分配;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/errors.ts b/packages/subprocess/win32-process/src/errors.ts index 84bd2a7ac2..f6e90805a1 100644 --- a/packages/subprocess/win32-process/src/errors.ts +++ b/packages/subprocess/win32-process/src/errors.ts @@ -2,7 +2,7 @@ export class Win32Error extends Error { /** Win32 function whose checked result failed. */ readonly api: string - /** Exact GetLastError value captured before cleanup changed it. */ + /** Exact GetLastError value or direct Win32 API error code. */ readonly win32Code: number constructor(api: string, win32Code: number, detail?: string) { diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index c9fb888515..f63943728e 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -240,14 +240,21 @@ export function spawnPipedProcess( * Drain one anonymous pipe until the writer closes it. * @param api - active binding table. * @param handle - caller-owned pipe read end. + * @param signal - optional cancellation that stops polling and closes the read end. * @returns complete bytes read before EOF; the handle is always closed. + * @throws when cancellation or a Win32 pipe operation fails. */ -export async function drainPipe(api: Win32ProcessBindings, handle: NativePtr): Promise { +export async function drainPipe( + api: Win32ProcessBindings, + handle: NativePtr, + signal?: AbortSignal, +): Promise { const chunks: Buffer[] = [] let countSlot: NativePtr | undefined try { countSlot = allocUint32() for (;;) { + if (signal?.aborted === true) throw new Error('pipe drain aborted') const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) if (peeked === 0) { const win32Code = api.getLastError() diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index c734d13013..15cf2e62c6 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -234,6 +234,24 @@ describe('wait and pipe cleanup', () => { expect(closeHandle).toHaveBeenCalledWith(80n) }) + it('stops polling and closes the read end when cancelled', async () => { + const controller = new AbortController() + const closeHandle = vi.fn(() => 1) + const peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, available) => { + koffi.encode(available, 'uint32', 0) + return 1 + }) + const api = { + peekNamedPipe, + closeHandle, + } as unknown as Win32ProcessBindings + const draining = drainPipe(api, 80n as NativePtr, controller.signal) + controller.abort() + await expect(draining).rejects.toThrow('pipe drain aborted') + expect(peekNamedPipe).toHaveBeenCalledOnce() + expect(closeHandle).toHaveBeenCalledWith(80n) + }) + it('checks caller-owned handle closure', () => { const closeHandle = vi.fn(() => 1) const api = { closeHandle } as unknown as Win32ProcessBindings diff --git a/packages/subprocess/win32-process/tsconfig.json b/packages/subprocess/win32-process/tsconfig.json index 2f159cfc48..730993dc97 100644 --- a/packages/subprocess/win32-process/tsconfig.json +++ b/packages/subprocess/win32-process/tsconfig.json @@ -6,6 +6,9 @@ }, "include": ["src"], "references": [ + { + "path": "../../../vendor/cordis" + }, { "path": "../../runtime-diagnostics/invariants" } From 03186fe93f1215fb2f322a42802aa6993a91b7c9 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 08:45:54 +0800 Subject: [PATCH 15/23] fix(sandbox): cancel sibling drain after child termination --- ...-shared-win32-process-primitives.i18n.yaml | 4 ++-- ...6-08-19-shared-win32-process-primitives.md | 2 +- ...8-19-shared-win32-process-primitives.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/index.ts | 22 +++++++++++-------- .../tests/index-failure-paths.spec.ts | 16 +++++--------- .../subprocess/win32-process/src/process.ts | 4 ++-- .../win32-process/tests/process.spec.ts | 5 +++-- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 5e1aeb4d0f..4e5dac463c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: ae7720273333f675b9fb4178405bedbc32982a59 -2026-08-19-shared-win32-process-primitives.zh.md: 2d8b9d3421fa4eb4100f4f016018301e21de1eef +2026-08-19-shared-win32-process-primitives.md: a190fbd78d3f6e33e5626b01a38a9c2cfbb8216f +2026-08-19-shared-win32-process-primitives.zh.md: 79e1ae576bc0d14d0e4f152825171d3d8510bb70 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index ae77202733..a190fbd78d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement terminates the child before its synchronous wait. When termination itself fails, settlement cancels and joins the sibling drain before closing the process handle and reporting the failure, so rejection leaves no polling timer alive. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement requests direct-child termination, cancels and joins the sibling drain, then performs the direct-child wait only when termination succeeded. A termination failure instead closes the process handle and reports both failures. Either result leaves no polling timer alive even when a descendant inherited a pipe writer. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 2d8b9d3421..79e1ae576b 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child。若终止本身失败,settlement 会先取消并等待 sibling drain 结束,再关闭 process handle 并报告失败,因此 rejection 不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会请求终止 direct child,取消并等待 sibling drain,再只在终止成功时执行 direct-child wait。若终止本身失败,则关闭 process handle 并同时报告两项失败。即使 descendant 继承了 pipe writer,两种结果也都不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 2dc33306af..64d49faf35 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -381,13 +381,14 @@ export class AclSandbox { const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) const drainAbort = new AbortController() + const drainCancellation = new Error('piped child drain cancelled after peer failure') const stdout = drainPipe(api, native.stdoutRead, drainAbort.signal) const stderr = drainPipe(api, native.stderrRead, drainAbort.signal) // WaitForSingleObject blocks the thread, so settlement starts it only after // both drains settle. Successful drains mean the child closed its pipe ends - // and the wait returns immediately. A failed drain terminates the child - // before waiting, so a native pipe failure cannot pin the event loop on a - // still-running command. + // and the wait returns immediately. A failed drain cancels its sibling and + // terminates the child before waiting, so inherited pipe writers cannot pin + // the event loop after settlement. let settlement: Promise | undefined return { pid: native.pid, @@ -400,11 +401,12 @@ export class AclSandbox { { status: 'fulfilled', value: stderrBuffer }, ] } catch (firstDrainFailure) { - if (api.terminateProcess(native.process, 1) === 0) { + const terminated = api.terminateProcess(native.process, 1) + const terminationCode = terminated === 0 ? api.getLastError() : 0 + drainAbort.abort(drainCancellation) + const settledDrains = await Promise.allSettled([stdout, stderr]) + if (terminated === 0) { const failures: unknown[] = [firstDrainFailure] - const terminationCode = api.getLastError() - drainAbort.abort() - await Promise.allSettled([stdout, stderr]) try { closeHandleChecked(api, native.process, 'piped child after drain failure') } catch (error) { @@ -413,10 +415,12 @@ export class AclSandbox { failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) throw new AggregateError(failures, 'piped child settlement failed') } - drains = await Promise.allSettled([stdout, stderr]) + drains = settledDrains } const failures = drains.flatMap(outcome => - outcome.status === 'rejected' ? [outcome.reason as unknown] : []) + outcome.status === 'rejected' && outcome.reason !== drainCancellation + ? [outcome.reason as unknown] + : []) let exitCode = 0 try { exitCode = waitForExit(api, native.process) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 41ca5463f1..159b36428f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -469,23 +469,14 @@ describe('AclSandbox spawn', () => { it('pipe spawn terminates promptly when one drain fails and the sibling remains open', async () => { const { api } = state.stubs as HappyStubs let peekCount = 0 - let terminated = false - let lastError = 5 api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { peekCount += 1 if (peekCount === 1) return 0 - if (terminated) { - lastError = ERROR_BROKEN_PIPE - return 0 - } koffi.encode(totalAvail, 'uint32', 0) return 1 }) - api.getLastError = vi.fn(() => lastError) - const terminateProcess = vi.fn(() => { - terminated = true - return 1 - }) + api.getLastError = vi.fn(() => 5) + const terminateProcess = vi.fn(() => 1) api.terminateProcess = terminateProcess const waitForSingleObject = vi.fn(() => 0) api.waitForSingleObject = waitForSingleObject @@ -494,6 +485,9 @@ describe('AclSandbox spawn', () => { await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe' }) await expect(child.wait()).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + const settledPeekCount = peekCount + await new Promise(resolve => setTimeout(resolve, 5)) + expect(peekCount).toBe(settledPeekCount) expect(terminateProcess).toHaveBeenCalledOnce() expect(waitForSingleObject).toHaveBeenCalledOnce() }) diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index f63943728e..4b948e1849 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -242,7 +242,7 @@ export function spawnPipedProcess( * @param handle - caller-owned pipe read end. * @param signal - optional cancellation that stops polling and closes the read end. * @returns complete bytes read before EOF; the handle is always closed. - * @throws when cancellation or a Win32 pipe operation fails. + * @throws when the drain is cancelled or a Win32 pipe operation fails. */ export async function drainPipe( api: Win32ProcessBindings, @@ -254,7 +254,7 @@ export async function drainPipe( try { countSlot = allocUint32() for (;;) { - if (signal?.aborted === true) throw new Error('pipe drain aborted') + signal?.throwIfAborted() const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) if (peeked === 0) { const win32Code = api.getLastError() diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index 15cf2e62c6..e2f151c8e1 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -246,8 +246,9 @@ describe('wait and pipe cleanup', () => { closeHandle, } as unknown as Win32ProcessBindings const draining = drainPipe(api, 80n as NativePtr, controller.signal) - controller.abort() - await expect(draining).rejects.toThrow('pipe drain aborted') + const cancellation = new Error('stop pipe drain') + controller.abort(cancellation) + await expect(draining).rejects.toBe(cancellation) expect(peekNamedPipe).toHaveBeenCalledOnce() expect(closeHandle).toHaveBeenCalledWith(80n) }) From a163f4019b15437844951842288aa7002166c9a7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 08:58:12 +0800 Subject: [PATCH 16/23] fix(sandbox): preserve all drain failures --- packages/sandbox/sandbox-windows-acl/src/index.ts | 7 +++++-- .../sandbox-windows-acl/tests/index-failure-paths.spec.ts | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 64d49faf35..989f31d86a 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -400,13 +400,16 @@ export class AclSandbox { { status: 'fulfilled', value: stdoutBuffer }, { status: 'fulfilled', value: stderrBuffer }, ] - } catch (firstDrainFailure) { + } catch { const terminated = api.terminateProcess(native.process, 1) const terminationCode = terminated === 0 ? api.getLastError() : 0 drainAbort.abort(drainCancellation) const settledDrains = await Promise.allSettled([stdout, stderr]) if (terminated === 0) { - const failures: unknown[] = [firstDrainFailure] + const failures = settledDrains.flatMap(outcome => + outcome.status === 'rejected' && outcome.reason !== drainCancellation + ? [outcome.reason as unknown] + : []) try { closeHandleChecked(api, native.process, 'piped child after drain failure') } catch (error) { diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 159b36428f..5cad94742a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -530,9 +530,11 @@ describe('AclSandbox spawn', () => { await expect(settlement).rejects.toBeInstanceOf(AggregateError) const failure = await settlement.catch((error: unknown): unknown => error) if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') - const apis = (failure.errors as unknown[]) + const errors = failure.errors as unknown[] + const apis = errors .filter((error): error is Win32Error => error instanceof Win32Error) .map(error => error.api) + expect(apis.filter(api => api === 'PeekNamedPipe')).toHaveLength(2) expect(apis).toEqual(expect.arrayContaining(['CloseHandle', 'TerminateProcess'])) }) }) From 5b47da02aee90da2b369b0a8c1beb08968859caf Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 15:21:01 +0800 Subject: [PATCH 17/23] refactor(win32-process): restore mechanical extraction --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 8 +- ...8-19-shared-win32-process-primitives.zh.md | 8 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 2 +- .../2026-07-26-ci-failover-runbook.zh.md | 2 +- .github/workflows/ci.yml | 8 - .../sandbox/sandbox-windows-acl/src/index.ts | 96 +++-------- .../sandbox/sandbox-windows-acl/src/spawn.ts | 2 +- .../tests/index-failure-paths.spec.ts | 150 +----------------- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 7 +- .../subprocess/win32-process/README.zh.md | 7 +- packages/subprocess/win32-process/src/abi.ts | 10 +- packages/subprocess/win32-process/src/ffi.ts | 27 +--- .../subprocess/win32-process/src/index.ts | 1 - .../win32-process/src/job-attribute.ts | 124 --------------- .../subprocess/win32-process/src/process.ts | 74 +++++---- .../win32-process/tests/job-attribute.spec.ts | 65 -------- .../tests/process-allocation-failure.spec.ts | 26 +-- .../tests/process-failure-paths.spec.ts | 74 ++++----- .../win32-process/tests/process.spec.ts | 137 +++++++--------- .../win32-process/verify/abi-probe.cpp | 10 +- scripts/ci-workflow.spec.ts | 15 +- scripts/verify-win32-abi.ps1 | 25 --- 28 files changed, 188 insertions(+), 710 deletions(-) delete mode 100644 packages/subprocess/win32-process/src/job-attribute.ts delete mode 100644 packages/subprocess/win32-process/tests/job-attribute.spec.ts delete mode 100644 scripts/verify-win32-abi.ps1 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 4e5dac463c..780aa7e236 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: a190fbd78d3f6e33e5626b01a38a9c2cfbb8216f -2026-08-19-shared-win32-process-primitives.zh.md: 79e1ae576bc0d14d0e4f152825171d3d8510bb70 +2026-08-19-shared-win32-process-primitives.md: 8765e5f7350dab56ad42169f6e16b55679ca8982 +2026-08-19-shared-win32-process-primitives.zh.md: b21ece8445e8863c08818c42d6c9bf7672813823 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index a190fbd78d..8765e5f735 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement requests direct-child termination, cancels and joins the sibling drain, then performs the direct-child wait only when termination succeeded. A termination failure instead closes the process handle and reports both failures. Either result leaves no polling timer alive even when a descendant inherited a pipe writer. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation starts the target suspended, assigns it to the kill-on-close Job, and resumes it only after assignment, so target code cannot run outside the Job. Assignment failure terminates the suspended target before releasing its handles; resume failure closes the assigned Job. The sandbox retains its existing pipe-drain, direct-wait, result, and returned-Job lifecycle. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic Job attachment during creation, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended creation followed by Job assignment and resume, wait and exit-code reads, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the migrated ABI and native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -32,4 +32,4 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, ## Consequences -The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Future process consumers extend the low-level package only when their production path exists. +The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Suspended creation guarantees that target code starts only after Job assignment, but it does not make the runner's create-to-assignment interval atomic against external termination. Future process consumers extend the low-level package only when their production path exists. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 79e1ae576b..b21ece8445 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW`、`STARTUPINFOEXW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会请求终止 direct child,取消并等待 sibling drain,再只在终止成功时执行 direct-child wait。若终止本身失败,则关闭 process handle 并同时报告两项失败。即使 descendant 继承了 pipe writer,两种结果也都不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。分配失败会先终止 suspended target 再释放句柄;恢复失败会关闭已经分配的 Job。sandbox 保留既有 pipe-drain、direct-wait、result 与返回 Job 的生命周期。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、创建时的原子 Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、suspended 创建后的 Job 分配与恢复、wait 与 exit-code 读取、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖迁移后的 ABI 和 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -32,4 +32,4 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF ## Consequences -sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。后续 process consumer 只在其生产路径存在时扩展低层 package。 +sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。suspended 创建保证目标代码只在 Job 分配后启动,但不会让 runner 的 create-to-assignment 区间对外部终止具备原子性。后续 process consumer 只在其生产路径存在时扩展低层 package。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 55592adfb6..f8cdf8e924 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: c4d1677d8f8f632ae31cf5bcfbbd5386c9932919 -2026-07-26-ci-failover-runbook.zh.md: bce9054e051d8c919b038337922174e33ad60f9c +2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc +2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index c4d1677d8f..e8a1d1dc33 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -24,7 +24,7 @@ The decision belongs at workflow level because cancellation applies to the whole #### Windows pool -`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, Visual Studio C++ Build Tools with the x64 MSVC toolchain and Windows SDK, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: before the complete aggregate, that lane compiles and runs the same two Win32 header ABI probes as `windows-native`, so a green standby verifies both the compiler prerequisite and `check:ci:windows-complete` end-to-end. +`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. ### Switch (any repository writer, ~1 minute, no merge) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index bce9054e05..8f92b7b60c 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -24,7 +24,7 @@ Status: implemented #### Windows 池 -`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7、带 x64 MSVC 工具链与 Windows SDK 的 Visual Studio C++ Build Tools,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:该通道会在完整聚合前编译并运行与 `windows-native` 相同的两份 Win32 header ABI probe,因此绿色热备会同时验证编译器前置条件与 `check:ci:windows-complete` 端到端流程。 +`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池能端到端执行 `check:ci:windows-complete`。 ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002de361b8..741a6c4d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -506,10 +506,6 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Compile and run Win32 header ABI probes - shell: pwsh - run: ./scripts/verify-win32-abi.ps1 - - name: Run complete native Windows gate inventory shell: pwsh run: pnpm run check:ci:windows-complete @@ -645,10 +641,6 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Compile and run Win32 header ABI probes - shell: pwsh - run: ./scripts/verify-win32-abi.ps1 - - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 989f31d86a..9e4568c726 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -42,7 +42,7 @@ import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' -import { closeHandleChecked, Win32Error } from '@deepseek-ai/dsh-win32-process' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { grantWrite, revokeWrite } from './acl.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' @@ -356,88 +356,34 @@ export class AclSandbox { if (options.stdio === 'inherit') { const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) - let settlement: Promise | undefined + let exitCodePromise: Promise | undefined return { pid: native.pid, - wait: () => (settlement ??= new Promise((resolveResult) => { - const failures: unknown[] = [] - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - try { - closeHandleChecked(api, native.job, 'kill-on-close job') - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') - resolveResult({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode }) - })), + wait: async () => { + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + const exitCode = await exitCodePromise + if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + }, } } const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) - const drainAbort = new AbortController() - const drainCancellation = new Error('piped child drain cancelled after peer failure') - const stdout = drainPipe(api, native.stdoutRead, drainAbort.signal) - const stderr = drainPipe(api, native.stderrRead, drainAbort.signal) - // WaitForSingleObject blocks the thread, so settlement starts it only after - // both drains settle. Successful drains mean the child closed its pipe ends - // and the wait returns immediately. A failed drain cancels its sibling and - // terminates the child before waiting, so inherited pipe writers cannot pin - // the event loop after settlement. - let settlement: Promise | undefined + const stdout = drainPipe(api, native.stdoutRead) + const stderr = drainPipe(api, native.stderrRead) + // waitForExit is deliberately NOT started here: WaitForSingleObject blocks + // the thread and would starve the drains while the child is still running + // (pipe-buffer deadlock). The drains resolve only after the child closed + // its pipe ends — by then the wait returns immediately. + let exitCodePromise: Promise | undefined return { pid: native.pid, - wait: () => (settlement ??= (async () => { - let drains: PromiseSettledResult[] - try { - const [stdoutBuffer, stderrBuffer] = await Promise.all([stdout, stderr]) - drains = [ - { status: 'fulfilled', value: stdoutBuffer }, - { status: 'fulfilled', value: stderrBuffer }, - ] - } catch { - const terminated = api.terminateProcess(native.process, 1) - const terminationCode = terminated === 0 ? api.getLastError() : 0 - drainAbort.abort(drainCancellation) - const settledDrains = await Promise.allSettled([stdout, stderr]) - if (terminated === 0) { - const failures = settledDrains.flatMap(outcome => - outcome.status === 'rejected' && outcome.reason !== drainCancellation - ? [outcome.reason as unknown] - : []) - try { - closeHandleChecked(api, native.process, 'piped child after drain failure') - } catch (error) { - failures.push(error) - } - failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) - throw new AggregateError(failures, 'piped child settlement failed') - } - drains = settledDrains - } - const failures = drains.flatMap(outcome => - outcome.status === 'rejected' && outcome.reason !== drainCancellation - ? [outcome.reason as unknown] - : []) - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') - return { - stdout: (drains[0] as PromiseFulfilledResult).value, - stderr: (drains[1] as PromiseFulfilledResult).value, - exitCode, - } - })()), + wait: async () => { + const stdoutBuffer = await stdout + const stderrBuffer = await stderr + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } + }, } } diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 3336a309f1..a36b0253c4 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -39,7 +39,7 @@ export function spawnSandboxed( * @param api - ACL/token binding table. * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns process and Job handles after atomic attachment during creation. + * @returns process and Job handles after assignment and resume. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 5cad94742a..64899d9db8 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -145,15 +145,9 @@ function happyStubs(): HappyStubs { }) const createJobObjectW = vi.fn(() => fresh()) const setInformationJobObject = vi.fn(() => 1) - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }) - const updateProcThreadAttribute = vi.fn(() => 1) - const deleteProcThreadAttributeList = vi.fn() + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const terminateProcess = vi.fn(() => 1) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) const closeHandle = vi.fn(() => 1) @@ -167,8 +161,8 @@ function happyStubs(): HappyStubs { getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, - setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute, - deleteProcThreadAttributeList, getStdHandle, + setInformationJobObject, assignProcessToJobObject, resumeThread, terminateProcess, + getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { @@ -403,140 +397,6 @@ describe('AclSandbox spawn', () => { jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) }) - - it('inherit spawn caches one failing settlement and closes the Job once', async () => { - const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) - const jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - expect(closeHandle.mock.calls.filter(([handle]) => handle === jobHandle)).toHaveLength(1) - }) - - it('inherit spawn aggregates wait and Job-close failures', async () => { - const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-1', 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({ - errors: [ - expect.objectContaining({ api: 'WaitForSingleObject' }), - expect.objectContaining({ api: 'CloseHandle' }), - ], - }) - }) - - it('pipe spawn reports a wait failure after successful drains', async () => { - const { api } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-2', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - }) - - it('pipe spawn still closes the process after a drain failure', async () => { - const { api } = state.stubs as HappyStubs - api.getLastError = vi.fn(() => 5) - const terminateProcess = vi.fn(() => 1) - api.terminateProcess = terminateProcess - const waitForSingleObject = vi.fn(() => 0) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ api: 'PeekNamedPipe' }), - expect.objectContaining({ api: 'PeekNamedPipe' }), - ], - }) - expect(terminateProcess).toHaveBeenCalledOnce() - expect(waitForSingleObject).toHaveBeenCalledOnce() - }) - - it('pipe spawn terminates promptly when one drain fails and the sibling remains open', async () => { - const { api } = state.stubs as HappyStubs - let peekCount = 0 - api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { - peekCount += 1 - if (peekCount === 1) return 0 - koffi.encode(totalAvail, 'uint32', 0) - return 1 - }) - api.getLastError = vi.fn(() => 5) - const terminateProcess = vi.fn(() => 1) - api.terminateProcess = terminateProcess - const waitForSingleObject = vi.fn(() => 0) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2-1', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'PeekNamedPipe' }) - const settledPeekCount = peekCount - await new Promise(resolve => setTimeout(resolve, 5)) - expect(peekCount).toBe(settledPeekCount) - expect(terminateProcess).toHaveBeenCalledOnce() - expect(waitForSingleObject).toHaveBeenCalledOnce() - }) - - it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { - const { api, closeHandle } = state.stubs as HappyStubs - let peekCount = 0 - api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { - peekCount += 1 - if (peekCount === 1) return 0 - koffi.encode(totalAvail, 'uint32', 0) - return 1 - }) - api.getLastError = vi.fn(() => 5) - api.terminateProcess = vi.fn(() => 0) - const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') }) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-3', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toBeInstanceOf(AggregateError) - const settledPeekCount = peekCount - await new Promise(resolve => setTimeout(resolve, 5)) - expect(peekCount).toBe(settledPeekCount) - expect(waitForSingleObject).not.toHaveBeenCalled() - expect(closeHandle).toHaveBeenCalled() - }) - - it('pipe spawn aggregates process-handle closure failure after termination failure', async () => { - const { api } = state.stubs as HappyStubs - api.getLastError = vi.fn(() => 5) - api.terminateProcess = vi.fn(() => 0) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-4', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - api.closeHandle = vi.fn(() => 0) - const settlement = child.wait() - await expect(settlement).rejects.toBeInstanceOf(AggregateError) - const failure = await settlement.catch((error: unknown): unknown => error) - if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') - const errors = failure.errors as unknown[] - const apis = errors - .filter((error): error is Win32Error => error instanceof Win32Error) - .map(error => error.api) - expect(apis.filter(api => api === 'PeekNamedPipe')).toHaveLength(2) - expect(apis).toEqual(expect.arrayContaining(['CloseHandle', 'TerminateProcess'])) - }) }) describe('AclSandbox dispose', () => { diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 62da073509..32344c63b8 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/README.md -README.md: 56d6c04af92fa07673e3f8881bf20e47358fd001 -README.zh.md: 8b7db95e0196dbc98a67657478e4e242b4f7bca9 +README.md: 790db2c3c82fc9e359cae6a0ff1eab156b2776b5 +README.zh.md: e6ac837e0c0408720d46609edf154d423a96c11b diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 56d6c04af9..790db2c3c8 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,7 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | -| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, atomic Job attachment, waits, and handle cleanup | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, suspended Job assignment, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 8b7db95e01..e6ac837e0c 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | -| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、原子 Job 附加、wait 与句柄清理的唯一 Koffi owner | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、suspended Job 分配、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 24f7b1f607..d9bb79be51 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md -README.md: 3e82c10b7894b15d970b794429c69c6923632bb9 -README.zh.md: b1afc1a7222189329cbd84d9729fbafc3ecd3acb +README.md: 0005416bdfac6101090a3dc87defd71e15ec7537 +README.zh.md: 2c505ea5a1ec2fe2a930eca035b8a64ca3d4ba4f diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 3e82c10b78..0005416bdf 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -6,11 +6,11 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. -- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, and attaches that Job through `STARTUPINFOEXW` while creating the restricted child. The child is Job-owned before any user code can run; attribute setup or creation failure closes every owned resource, and no successful process creation can leave an unowned child. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining, accepts cancellation that stops polling, and frees its allocation before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. The sandbox retains its existing scheduling, result composition, and caller-owned Job closure. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. @@ -36,4 +36,5 @@ The package contributes no stable request prefix, so it does not invalidate mode - **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. - **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process. - **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. +- **Create-to-assignment interruption** — the target starts suspended and cannot execute before Job assignment, but an external termination of the runner in the narrow interval between process creation and assignment can leave the suspended target behind. The package does not claim atomic Job attachment. - **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index b1afc1a722..2c505ea5a1 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -6,11 +6,11 @@ ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 -- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,并在创建 restricted child 时通过 `STARTUPINFOEXW` 附加该 Job。child 会在任何用户代码运行前归属 Job;attribute 设置或创建失败都会关闭全部已拥有资源,成功创建进程后不会留下无 owner 的 child。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,接受停止轮询的取消信号,并在关闭管道读取句柄前释放原生分配;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。sandbox 保留既有调度、result 组合与调用方拥有的 Job 关闭行为。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 @@ -36,4 +36,5 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 - **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 - **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW` 以 `ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 - **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 +- **创建到分配之间的中断** — 目标以 suspended 状态启动,不能在 Job 分配前执行,但 runner 若在进程创建到分配之间的极窄区间被外力终止,可能留下 suspended target。本包不声明原子 Job 附加保证。 - **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 9409e9025b..fbdda9059f 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,10 +6,8 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */ -export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 -/** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */ -export const PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D +/** CreateProcess flag that prevents user code from running before resume. */ +export const CREATE_SUSPENDED = 0x4 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -36,9 +34,5 @@ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 /** x64 STARTUPINFOW byte size verified by the native probe. */ export const STARTUPINFOW_SIZE = 104 -/** x64 STARTUPINFOEXW byte size verified by the native probe. */ -export const STARTUPINFOEXW_SIZE = 112 -/** x64 pointer and HANDLE byte size. */ -export const POINTER_SIZE = 8 /** x64 PROCESS_INFORMATION byte size verified by the native probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 4abea75c5e..a2171d0023 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -81,22 +81,6 @@ export interface Win32ProcessBindings { startupInfo: NativePtr, processInfo: NativePtr, ): number - initializeProcThreadAttributeList( - attributeList: Buffer | null, - attributeCount: number, - flags: number, - size: NativePtr, - ): number - updateProcThreadAttribute( - attributeList: Buffer, - flags: number, - attribute: number, - value: NativePtr, - size: number, - previousValue: null, - returnSize: null, - ): number - deleteProcThreadAttributeList(attributeList: Buffer): void readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, @@ -110,6 +94,8 @@ export interface Win32ProcessBindings { getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + resumeThread(thread: NativePtr): number terminateProcess(process: NativePtr, exitCode: number): number getStdHandle(stdHandle: number): NativePtr } @@ -255,13 +241,6 @@ function bindings(): Win32ProcessBindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), - initializeProcThreadAttributeList: bind(kernel32, 'InitializeProcThreadAttributeList', 'int', [ - PVOID, 'uint32', 'uint32', koffi.pointer('size_t'), - ]), - updateProcThreadAttribute: bind(kernel32, 'UpdateProcThreadAttribute', 'int', [ - PVOID, 'uint32', 'size_t', PVOID, 'size_t', PVOID, PVOID, - ]), - deleteProcThreadAttributeList: bind(kernel32, 'DeleteProcThreadAttributeList', 'void', [PVOID]), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), @@ -270,6 +249,8 @@ function bindings(): Win32ProcessBindings { getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32ProcessBindings diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index fa7f2dd992..d6dee59d58 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -17,7 +17,6 @@ export type { Win32ProcessBindings, } from './ffi.ts' export { - closeHandleChecked, drainPipe, spawnInheritedJobProcess, spawnPipedProcess, diff --git a/packages/subprocess/win32-process/src/job-attribute.ts b/packages/subprocess/win32-process/src/job-attribute.ts deleted file mode 100644 index 7798cc6eea..0000000000 --- a/packages/subprocess/win32-process/src/job-attribute.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** Package-private STARTUPINFOEXW ownership for atomic Job attachment. */ - -import koffi from 'koffi' -import * as abi from './abi.ts' -import { STARTUPINFOW, throwWin32 } from './ffi.ts' -import type { NativePtr, StartupInfoInput, Win32ProcessBindings } from './ffi.ts' - -type Ptr = ReturnType -const PVOID: Ptr = koffi.pointer('void') - -const STARTUPINFOEXW = koffi.struct('DSH_STARTUPINFOEXW', { - StartupInfo: STARTUPINFOW, - lpAttributeList: PVOID, -}) - -/* v8 ignore start -- the native header probe pins this x64 layout. */ -if (STARTUPINFOEXW.size !== abi.STARTUPINFOEXW_SIZE) { - throw new Error(`STARTUPINFOEXW layout mismatch: koffi computed ${STARTUPINFOEXW.size}, expected ${abi.STARTUPINFOEXW_SIZE}`) -} -/* v8 ignore stop */ - -/** One extended startup record whose attribute list remains valid through CreateProcess. */ -export interface JobStartupInfo { - /** STARTUPINFOEXW pointer passed to CreateProcessAsUserW. */ - readonly pointer: NativePtr - /** Release the initialized process attribute list after CreateProcessAsUserW returns. */ - dispose(): void -} - -function queryAttributeListSize(api: Win32ProcessBindings): number { - const sizeSlot = koffi.alloc('size_t', 1) as NativePtr - try { - api.initializeProcThreadAttributeList(null, 1, 0, sizeSlot) - const attributeBytes = koffi.decode(sizeSlot, 'size_t') as number - if (attributeBytes === 0) { - throwWin32( - api, - 'InitializeProcThreadAttributeList', - api.getLastError(), - 'process-attribute size query', - ) - } - return attributeBytes - } finally { - koffi.free(sizeSlot) - } -} - -/** - * Build a STARTUPINFOEXW that assigns the restricted child to `job` during creation. - * @param api - active binding table. - * @param fields - inherited stdio fields for the nested STARTUPINFOW. - * @param job - caller-owned Job attached before any child thread exists. - * @returns extended startup pointer and its post-CreateProcess disposer. - */ -export function createJobStartupInfo( - api: Win32ProcessBindings, - fields: Omit, - job: NativePtr, -): JobStartupInfo { - const attributeList = Buffer.alloc(queryAttributeListSize(api)) - const sizeSlot = koffi.alloc('size_t', 1) as NativePtr - let initialized = false - let jobList: NativePtr | undefined - try { - koffi.encode(sizeSlot, 'size_t', attributeList.length) - if (api.initializeProcThreadAttributeList(attributeList, 1, 0, sizeSlot) === 0) { - throwWin32( - api, - 'InitializeProcThreadAttributeList', - api.getLastError(), - 'process-attribute initialization', - ) - } - initialized = true - jobList = koffi.alloc(PVOID, 1) as NativePtr - koffi.encode(jobList, PVOID, job) - if (api.updateProcThreadAttribute( - attributeList, - 0, - abi.PROC_THREAD_ATTRIBUTE_JOB_LIST, - jobList, - abi.POINTER_SIZE, - null, - null, - ) === 0) { - throwWin32( - api, - 'UpdateProcThreadAttribute', - api.getLastError(), - 'PROC_THREAD_ATTRIBUTE_JOB_LIST', - ) - } - const pointer = koffi.alloc(STARTUPINFOEXW, 1) as NativePtr - try { - koffi.encode(pointer, STARTUPINFOEXW, { - StartupInfo: { ...fields, cb: abi.STARTUPINFOEXW_SIZE }, - lpAttributeList: attributeList, - }) - } catch (error) { - /* v8 ignore start -- staging a STARTUPINFOEXW encode failure requires replacing Koffi's encoder. */ - koffi.free(pointer) - throw error - /* v8 ignore stop */ - } - return { - pointer, - dispose: () => { - try { - api.deleteProcThreadAttributeList(attributeList) - } finally { - koffi.free(jobList) - koffi.free(pointer) - } - }, - } - } catch (error) { - if (initialized) api.deleteProcThreadAttributeList(attributeList) - if (jobList !== undefined) koffi.free(jobList) - throw error - } finally { - koffi.free(sizeSlot) - } -} diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 4b948e1849..7c676e1f7f 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -15,7 +15,6 @@ import { throwLastError, throwWin32, } from './ffi.ts' -import { createJobStartupInfo } from './job-attribute.ts' import type { NativePtr, Win32ProcessBindings } from './ffi.ts' /** @@ -78,7 +77,7 @@ export interface SpawnedPipedProcess { stderrRead: NativePtr } -/** Child atomically attached to one caller-owned kill-on-close Job during creation. */ +/** Suspended child assigned to one caller-owned kill-on-close Job before resume. */ export interface SpawnedJobProcess { /** Direct child process id. */ pid: number @@ -240,21 +239,18 @@ export function spawnPipedProcess( * Drain one anonymous pipe until the writer closes it. * @param api - active binding table. * @param handle - caller-owned pipe read end. - * @param signal - optional cancellation that stops polling and closes the read end. * @returns complete bytes read before EOF; the handle is always closed. - * @throws when the drain is cancelled or a Win32 pipe operation fails. + * @throws when a Win32 pipe operation fails. */ export async function drainPipe( api: Win32ProcessBindings, handle: NativePtr, - signal?: AbortSignal, ): Promise { const chunks: Buffer[] = [] let countSlot: NativePtr | undefined try { countSlot = allocUint32() for (;;) { - signal?.throwIfAborted() const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) if (peeked === 0) { const win32Code = api.getLastError() @@ -321,10 +317,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { } /** - * Spawn atomically attached to a kill-on-close Job. + * Spawn suspended, assign the child to a kill-on-close Job, then resume it. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. - * @returns caller-owned process and Job handles after successful creation. + * @returns caller-owned process and Job handles after successful resume. * @remarks Node clears stdio handle inheritability at startup through * uv_disable_stdio_inheritance. This operation temporarily restores the bits * required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort: @@ -346,6 +342,7 @@ export function spawnInheritedJobProcess( const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] + let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined let created = 0 let createFailureCode = 0 @@ -360,31 +357,30 @@ export function spawnInheritedJobProcess( } enabled.push(handle) } - const startupInfo = createJobStartupInfo(api, { + startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, dwFlags: abi.STARTF_USESTDHANDLES, hStdInput: stdIn, hStdOutput: stdOut, hStdError: stdErr, - }, job) - try { - processInfo = allocProcessInfo() - created = createRestrictedProcess( - api, - options, - buildCommandLine(options.command, options.args), - abi.EXTENDED_STARTUPINFO_PRESENT, - startupInfo.pointer, - processInfo, - ) - if (created === 0) createFailureCode = api.getLastError() - } finally { - startupInfo.dispose() - } + }) + processInfo = allocProcessInfo() + created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + ) + if (created === 0) createFailureCode = api.getLastError() } catch (error) { freeNative(processInfo) api.closeHandle(job) throw error } finally { + freeNative(startupInfo) for (const handle of enabled) { // The runner spawns nothing else; cleanup failure must not mask the child. api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) @@ -407,25 +403,27 @@ export function spawnInheritedJobProcess( freeNative(processInfo) } if (info.hProcess === null || info.hThread === null) { + if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } + if (api.assignProcessToJobObject(job, info.hProcess) === 0) { + const win32Code = api.getLastError() + api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { + const win32Code = api.getLastError() + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } closeBestEffort(api, info.hThread) return { pid: info.dwProcessId, process: info.hProcess, job } } - -/** - * Close a handle and surface a failure without losing its operation label. - * @param api - active binding table. - * @param handle - caller-owned handle to close. - * @param detail - lifecycle label included in a failure. - */ -export function closeHandleChecked( - api: Win32ProcessBindings, - handle: NativePtr, - detail: string, -): void { - if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', detail) -} diff --git a/packages/subprocess/win32-process/tests/job-attribute.spec.ts b/packages/subprocess/win32-process/tests/job-attribute.spec.ts deleted file mode 100644 index 793cb62bdd..0000000000 --- a/packages/subprocess/win32-process/tests/job-attribute.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import koffi from 'koffi' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createJobStartupInfo } from '../src/job-attribute.ts' -import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function bindings(): { - api: Win32ProcessBindings - deleteProcThreadAttributeList: ReturnType -} { - const deleteProcThreadAttributeList = vi.fn() - const api = { - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList, - getLastError: vi.fn(() => 5), - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - return { api, deleteProcThreadAttributeList } -} - -const fields = { - dwFlags: 0x100, - hStdInput: 1n as NativePtr, - hStdOutput: 2n as NativePtr, - hStdError: 3n as NativePtr, -} - -describe('createJobStartupInfo allocation cleanup', () => { - it('frees the size slot when attribute-list buffer allocation throws', () => { - const { api, deleteProcThreadAttributeList } = bindings() - const free = vi.spyOn(koffi, 'free') - vi.spyOn(Buffer, 'alloc').mockImplementationOnce(() => { throw new Error('buffer allocation failed') }) - expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('buffer allocation failed') - expect(free).toHaveBeenCalledOnce() - expect(deleteProcThreadAttributeList).not.toHaveBeenCalled() - }) - - it('deletes the initialized list and frees the Job value when attachment fails', () => { - const { api, deleteProcThreadAttributeList } = bindings() - api.updateProcThreadAttribute = vi.fn(() => 0) - const free = vi.spyOn(koffi, 'free') - expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('PROC_THREAD_ATTRIBUTE_JOB_LIST') - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - expect(free).toHaveBeenCalledTimes(3) - }) - - it('frees every native allocation after the caller disposes the startup record', () => { - const { api, deleteProcThreadAttributeList } = bindings() - const free = vi.spyOn(koffi, 'free') - const startup = createJobStartupInfo(api, fields, 50n as NativePtr) - startup.dispose() - expect(free).toHaveBeenCalledTimes(4) - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts index f9e115e8d0..fe3c603c02 100644 --- a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -20,21 +20,11 @@ afterEach(() => { describe('spawnInheritedJobProcess allocation cleanup', () => { it('frees startup info when process-info allocation throws', () => { - const deleteProcThreadAttributeList = vi.fn() const api = { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList, closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -47,8 +37,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toThrow('process-info allocation failed') - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - expect(free).toHaveBeenCalledTimes(4) + expect(free).toHaveBeenCalledOnce() }) it('frees process info after a successful inherited spawn', () => { @@ -57,15 +46,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList: vi.fn(), createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -75,6 +55,8 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { }) return 1 }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -86,7 +68,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(free).toHaveBeenCalledTimes(5) + expect(free).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 93a501c197..2f7f021348 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -21,23 +21,6 @@ import { PROCESS_INFORMATION } from '../src/ffi.ts' const PVOID = koffi.pointer('void') -function jobAttributeStubs(): Pick< - Win32ProcessBindings, - 'initializeProcThreadAttributeList' | 'updateProcThreadAttribute' | 'deleteProcThreadAttributeList' -> { - return { - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList: vi.fn(), - } -} - /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType } { const closed: bigint[] = [] @@ -205,7 +188,9 @@ describe('spawnInheritedJobProcess failure paths', () => { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) return 1 }), - ...jobAttributeStubs(), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), + terminateProcess: vi.fn(() => 1), getLastError: vi.fn(() => 5), closeHandle, formatMessageW: vi.fn(() => 0), @@ -267,34 +252,11 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(100n) }) - it('closes the job when the attribute-list size query returns no size', () => { + it('terminates the suspended child before closing handles when Job assignment fails', () => { + const terminateProcess = vi.fn(() => 1) const { api, closeHandle } = inheritedApi({ - initializeProcThreadAttributeList: vi.fn(() => 0), - }) - expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) - .toThrow(Win32Error) - expect(closeHandle).toHaveBeenCalledWith(100n) - }) - - it('closes the job when attribute-list initialization fails', () => { - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 0 - }) - const { api, closeHandle } = inheritedApi({ initializeProcThreadAttributeList }) - expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) - .toThrow(Win32Error) - expect(closeHandle).toHaveBeenCalledWith(100n) - }) - - it('deletes the attribute list and closes the job when atomic Job attachment fails', () => { - const deleteProcThreadAttributeList = vi.fn() - const { api, closeHandle } = inheritedApi({ - updateProcThreadAttribute: vi.fn(() => 0), - deleteProcThreadAttributeList, + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, }) let caught: unknown try { @@ -302,8 +264,24 @@ describe('spawnInheritedJobProcess failure paths', () => { } catch (error) { caught = error } - expect(caught).toMatchObject({ api: 'UpdateProcThreadAttribute', win32Code: 5 }) - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 }) + expect(terminateProcess).toHaveBeenCalledWith(200n, 1) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the assigned child and Job when ResumeThread fails', () => { + const { api, closeHandle } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'ResumeThread', win32Code: 5 }) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) expect(closeHandle).toHaveBeenCalledWith(100n) }) @@ -342,6 +320,8 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).not.toHaveBeenCalledWith(200n) expect(closeHandle).not.toHaveBeenCalledWith(100n) + expect(api.assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n) + expect(api.resumeThread).toHaveBeenCalledWith(201n) }) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index e2f151c8e1..3fbaa570fe 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -2,16 +2,11 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { Win32Error, - closeHandleChecked, drainPipe, spawnInheritedJobProcess, spawnPipedProcess, } from '../src/index.ts' -import { - EXTENDED_STARTUPINFO_PRESENT, - POINTER_SIZE, - PROC_THREAD_ATTRIBUTE_JOB_LIST, -} from '../src/abi.ts' +import { CREATE_SUSPENDED } from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -21,12 +16,10 @@ function inheritedApi(overrides: Partial = {}): { api: Win32ProcessBindings events: string[] createProcessAsUserW: ReturnType - initializeProcThreadAttributeList: ReturnType - updateProcThreadAttribute: ReturnType - attachedJob: () => NativePtr | null + assignProcessToJobObject: ReturnType + resumeThread: ReturnType } { const events: string[] = [] - let attachedJob: NativePtr | null = null const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = overrides.createProcessAsUserW ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { @@ -40,20 +33,12 @@ function inheritedApi(overrides: Partial = {}): { return 1 }) const createProcessAsUserW = vi.fn(createProcessAsUserWImpl) - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - events.push('attribute-size') - koffi.encode(size, 'size_t', 64) - return 0 - } - events.push('attribute-init') + const assignProcessToJobObject = vi.fn(() => { + events.push('assign') return 1 }) - const updateProcThreadAttribute = vi.fn((_list, _flags, attribute: number, value: NativePtr) => { - if (attribute === PROC_THREAD_ATTRIBUTE_JOB_LIST) { - attachedJob = koffi.decode(value, PVOID) as NativePtr - events.push('attach-job') - } + const resumeThread = vi.fn(() => { + events.push('resume') return 1 }) const api = { @@ -64,9 +49,8 @@ function inheritedApi(overrides: Partial = {}): { events.push(flags === 0 ? 'restore' : 'inherit') return 1 }), - initializeProcThreadAttributeList, - updateProcThreadAttribute, - deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }), + assignProcessToJobObject, + resumeThread, terminateProcess: vi.fn(() => 1), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), getLastError: vi.fn(() => 5), @@ -78,23 +62,21 @@ function inheritedApi(overrides: Partial = {}): { api, events, createProcessAsUserW, - initializeProcThreadAttributeList, - updateProcThreadAttribute, - attachedJob: () => attachedJob, + assignProcessToJobObject, + resumeThread, } } describe('spawnInheritedJobProcess', () => { const token = 70n as NativePtr - it('attaches a restricted child to the Job inside CreateProcessAsUserW', () => { + it('creates suspended, assigns the Job, then resumes the restricted child', () => { const { api, events, createProcessAsUserW, - initializeProcThreadAttributeList, - updateProcThreadAttribute, - attachedJob, + assignProcessToJobObject, + resumeThread, } = inheritedApi() const child = spawnInheritedJobProcess(api, { command: 'cmd.exe', @@ -103,20 +85,10 @@ describe('spawnInheritedJobProcess', () => { token, }) expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create')) - expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create')) - expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything()) - expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything()) - expect(updateProcThreadAttribute).toHaveBeenCalledWith( - expect.any(Buffer), - 0, - PROC_THREAD_ATTRIBUTE_JOB_LIST, - expect.anything(), - POINTER_SIZE, - null, - null, - ) - expect(attachedJob()).toBe(50n) + expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign')) + expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume')) + expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n) + expect(resumeThread).toHaveBeenCalledWith(61n) expect(createProcessAsUserW).toHaveBeenCalledWith( token, null, @@ -124,7 +96,7 @@ describe('spawnInheritedJobProcess', () => { null, null, 1, - EXTENDED_STARTUPINFO_PRESENT, + CREATE_SUSPENDED, null, 'C:\\work', expect.anything(), @@ -187,10 +159,12 @@ describe('spawnInheritedJobProcess', () => { expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 }) }) - it('closes the atomic Job when CreateProcessAsUserW returns a null thread handle', () => { + it('terminates the suspended process and closes the Job when CreateProcessAsUserW returns a null thread handle', () => { const closeHandle = vi.fn(() => 1) + const terminateProcess = vi.fn(() => 1) const { api } = inheritedApi({ closeHandle, + terminateProcess, createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -207,9 +181,43 @@ describe('spawnInheritedJobProcess', () => { cwd: 'C:\\work', token, })).toThrow('null process/thread handles') + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) expect(closeHandle).toHaveBeenCalledWith(50n) expect(closeHandle).toHaveBeenCalledWith(60n) }) + + it('terminates the suspended child before closing handles when Job assignment fails', () => { + const closeHandle = vi.fn(() => 1) + const terminateProcess = vi.fn(() => 1) + const { api } = inheritedApi({ + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, + closeHandle, + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) + }) + + it('closes the assigned Job and process when ResumeThread fails', () => { + const closeHandle = vi.fn(() => 1) + const { api } = inheritedApi({ + resumeThread: vi.fn(() => 0xFFFFFFFF), + closeHandle, + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) + }) }) describe('wait and pipe cleanup', () => { @@ -234,39 +242,6 @@ describe('wait and pipe cleanup', () => { expect(closeHandle).toHaveBeenCalledWith(80n) }) - it('stops polling and closes the read end when cancelled', async () => { - const controller = new AbortController() - const closeHandle = vi.fn(() => 1) - const peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, available) => { - koffi.encode(available, 'uint32', 0) - return 1 - }) - const api = { - peekNamedPipe, - closeHandle, - } as unknown as Win32ProcessBindings - const draining = drainPipe(api, 80n as NativePtr, controller.signal) - const cancellation = new Error('stop pipe drain') - controller.abort(cancellation) - await expect(draining).rejects.toBe(cancellation) - expect(peekNamedPipe).toHaveBeenCalledOnce() - expect(closeHandle).toHaveBeenCalledWith(80n) - }) - - it('checks caller-owned handle closure', () => { - const closeHandle = vi.fn(() => 1) - const api = { closeHandle } as unknown as Win32ProcessBindings - expect(() => { closeHandleChecked(api, 80n as NativePtr, 'sandbox Job') }).not.toThrow() - expect(closeHandle).toHaveBeenCalledWith(80n) - - const failing = { - closeHandle: vi.fn(() => 0), - getLastError: vi.fn(() => 6), - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - expect(() => { closeHandleChecked(failing, 81n as NativePtr, 'sandbox Job') }).toThrow(Win32Error) - }) - it('terminates a piped child when CreateProcess returns a null thread handle', () => { let nextPipe = 10n const terminateProcess = vi.fn(() => 1) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 50452bd514..1c9480105d 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -13,14 +13,11 @@ int wmain() P(offsetof(STARTUPINFOW, hStdInput)); P(offsetof(STARTUPINFOW, hStdOutput)); P(offsetof(STARTUPINFOW, hStdError)); - P(sizeof(STARTUPINFOEXW)); - P(offsetof(STARTUPINFOEXW, lpAttributeList)); P(sizeof(PROCESS_INFORMATION)); P(offsetof(PROCESS_INFORMATION, hProcess)); P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); - P(EXTENDED_STARTUPINFO_PRESENT); - P(PROC_THREAD_ATTRIBUTE_JOB_LIST); + P(CREATE_SUSPENDED); P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); @@ -38,11 +35,8 @@ int wmain() P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); - static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size"); - static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag"); - static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute"); + static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index e4ab756424..cc43e06f55 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -49,8 +49,8 @@ describe('CI workflow', () => { const node24Coverage = workflow.jobs['node-24-coverage'] const node24Consumers = workflow.jobs['node-24-consumers'] const aggregate = workflow.jobs['all-checks-passed'] - if (!Array.isArray(windows.steps) || !Array.isArray(serialWindows.steps) || !Array.isArray(aggregate.needs)) { - throw new TypeError('Windows jobs must define steps and the aggregate must define needs') + if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows job must define steps and the aggregate must define needs') } const commandSteps = windows.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' @@ -78,7 +78,6 @@ describe('CI workflow', () => { const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) - expect(nativeCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') // wine-apt-cache: master-only, seeds the Wine apt cache. @@ -89,16 +88,6 @@ describe('CI workflow', () => { expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') - const serialWindowsCommandSteps = serialWindows.steps.filter((step): step is Record & { run: string } => ( - isRecord(step) && typeof step.run === 'string' - )) - expect(serialWindowsCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') - expect(serialWindowsCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - const abiProbeScript = readFileSync(resolve(root, 'scripts/verify-win32-abi.ps1'), 'utf8') - expect(abiProbeScript).toContain('vswhere.exe') - expect(abiProbeScript).toContain('vcvars64.bat') - expect(abiProbeScript).toContain('packages/subprocess/win32-process/verify/abi-probe.cpp') - expect(abiProbeScript).toContain('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') diff --git a/scripts/verify-win32-abi.ps1 b/scripts/verify-win32-abi.ps1 deleted file mode 100644 index c0125c9eab..0000000000 --- a/scripts/verify-win32-abi.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } -$probeRoot = Join-Path $temporaryRoot 'dsh-win32-abi-probes' -New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null - -$vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' -if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } -$vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() -if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } -$vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' -if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } - -$processProbe = Join-Path $probeRoot 'win32-process.exe' -$processObject = Join-Path $probeRoot 'win32-process.obj' -$processSource = Join-Path $repoRoot 'packages/subprocess/win32-process/verify/abi-probe.cpp' -$sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' -$sandboxObject = Join-Path $probeRoot 'sandbox-windows-acl.obj' -$sandboxSource = Join-Path $repoRoot 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' - -$probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$processObject`" /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$sandboxObject`" /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" -& cmd.exe /d /s /c $probeCommand -if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' } From 19256704c7bb72f537a74390625ec5a58707298e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 15:25:02 +0800 Subject: [PATCH 18/23] test(win32-process): type handle-order assertions --- packages/subprocess/win32-process/tests/process.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index 3fbaa570fe..263908082b 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -106,7 +106,7 @@ describe('spawnInheritedJobProcess', () => { it('restores already-enabled stdio and closes the Job when inheritance setup fails', () => { let calls = 0 - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const setHandleInformation = vi.fn((_handle: NativePtr, _mask: number, flags: number) => { if (flags === 0) return 1 calls += 1 @@ -160,7 +160,7 @@ describe('spawnInheritedJobProcess', () => { }) it('terminates the suspended process and closes the Job when CreateProcessAsUserW returns a null thread handle', () => { - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const terminateProcess = vi.fn(() => 1) const { api } = inheritedApi({ closeHandle, @@ -187,7 +187,7 @@ describe('spawnInheritedJobProcess', () => { }) it('terminates the suspended child before closing handles when Job assignment fails', () => { - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const terminateProcess = vi.fn(() => 1) const { api } = inheritedApi({ assignProcessToJobObject: vi.fn(() => 0), @@ -205,7 +205,7 @@ describe('spawnInheritedJobProcess', () => { }) it('closes the assigned Job and process when ResumeThread fails', () => { - const closeHandle = vi.fn(() => 1) + const closeHandle = vi.fn((_handle: NativePtr) => 1) const { api } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF), closeHandle, From 7ef1c458f062732a5e98f0b3b107b9a38c4a8dc9 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 16:03:15 +0800 Subject: [PATCH 19/23] fix(win32-process): close PR1 validation gaps --- .../sandbox/sandbox-windows-acl/src/index.ts | 2 -- .../sandbox-windows-acl/tests/ffi.spec.ts | 2 +- .../subprocess/win32-process/package.json | 2 +- .../tests/process-failure-paths.spec.ts | 14 +++++--- .../win32-process/tests/process.spec.ts | 32 ------------------- 5 files changed, 11 insertions(+), 41 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 9e4568c726..5ebdccbd6a 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -55,8 +55,6 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' -export { Win32Error } from '@deepseek-ai/dsh-win32-process' - /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 761598a073..760370b24f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import koffi from 'koffi' -import { Win32Error } from '../src/index.ts' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { allocBytes, decodePtrAt, getTempPath, isInvalidHandle, sameSidAt, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 7d6257d692..ac106cc927 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.0-rc.7", + "version": "0.1.0-rc.8", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 2f7f021348..41ba438516 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -254,7 +254,7 @@ describe('spawnInheritedJobProcess failure paths', () => { it('terminates the suspended child before closing handles when Job assignment fails', () => { const terminateProcess = vi.fn(() => 1) - const { api, closeHandle } = inheritedApi({ + const { api, closeHandle, closed } = inheritedApi({ assignProcessToJobObject: vi.fn(() => 0), terminateProcess, }) @@ -269,10 +269,11 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).toHaveBeenCalledWith(200n) expect(closeHandle).toHaveBeenCalledWith(100n) + expect(closed).toEqual([201n, 200n, 100n]) }) it('closes the assigned child and Job when ResumeThread fails', () => { - const { api, closeHandle } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) }) + const { api, closeHandle, closed } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) }) let caught: unknown try { spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) @@ -283,6 +284,7 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).toHaveBeenCalledWith(200n) expect(closeHandle).toHaveBeenCalledWith(100n) + expect(closed).toEqual([201n, 200n, 100n]) }) it('closes the job and reports when SetInformationJobObject fails', () => { @@ -311,7 +313,9 @@ describe('spawnInheritedJobProcess failure paths', () => { }) it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => { - const { api, closeHandle } = inheritedApi() + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const { api, closeHandle } = inheritedApi({ assignProcessToJobObject, resumeThread }) const spawned = spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) expect(spawned.pid).toBe(1234) expect(spawned.process).toBe(200n) @@ -320,8 +324,8 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).not.toHaveBeenCalledWith(200n) expect(closeHandle).not.toHaveBeenCalledWith(100n) - expect(api.assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n) - expect(api.resumeThread).toHaveBeenCalledWith(201n) + expect(assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n) + expect(resumeThread).toHaveBeenCalledWith(201n) }) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index 263908082b..85835a659c 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -186,38 +186,6 @@ describe('spawnInheritedJobProcess', () => { expect(closeHandle).toHaveBeenCalledWith(60n) }) - it('terminates the suspended child before closing handles when Job assignment fails', () => { - const closeHandle = vi.fn((_handle: NativePtr) => 1) - const terminateProcess = vi.fn(() => 1) - const { api } = inheritedApi({ - assignProcessToJobObject: vi.fn(() => 0), - terminateProcess, - closeHandle, - }) - expect(() => spawnInheritedJobProcess(api, { - command: 'cmd.exe', - args: [], - cwd: 'C:\\work', - token, - })).toThrow(Win32Error) - expect(terminateProcess).toHaveBeenCalledWith(60n, 1) - expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) - }) - - it('closes the assigned Job and process when ResumeThread fails', () => { - const closeHandle = vi.fn((_handle: NativePtr) => 1) - const { api } = inheritedApi({ - resumeThread: vi.fn(() => 0xFFFFFFFF), - closeHandle, - }) - expect(() => spawnInheritedJobProcess(api, { - command: 'cmd.exe', - args: [], - cwd: 'C:\\work', - token, - })).toThrow(Win32Error) - expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) - }) }) describe('wait and pipe cleanup', () => { From ff7a5a042c5ee0f77550954fe1717b0e06a396cd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 19:17:03 +0800 Subject: [PATCH 20/23] docs(win32-process): point ABI verification to its owner --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 +- packages/subprocess/win32-process/README.i18n.yaml | 4 ++-- packages/subprocess/win32-process/README.md | 10 ++++++++++ packages/subprocess/win32-process/README.zh.md | 10 ++++++++++ 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ace32ae8cf..10be54b7bb 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: c31f6452815c5629b49c302ebec408da1f0f4803 -README.zh.md: c6a87075875d3424b47121e32d8465a752149c89 +README.md: 91172cc0b2fcab1daceb75f7c02f3eecc679bab1 +README.zh.md: 0e8435e3d1a27a2868f97d6af4ce9e66953e8a26 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index c31f645281..91172cc0b2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -62,7 +62,7 @@ The `AclSandbox` class (explicit private `tempDir` + `tempWriteSid`, or `tempDir ## Header verification -All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts): +The sandbox-owned SID, ACL, token, file, and lock constants and layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `fileapi.h`) and are cross-checked by [`verify/abi-probe.cpp`](verify/abi-probe.cpp). The shared process, stdio, and Job ABI is owned and verified by [`@deepseek-ai/dsh-win32-process`](../../subprocess/win32-process/README.md#header-verification). ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index c6a8707587..0e8435e3d1 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -64,7 +64,7 @@ Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安 ## 头部验证 -所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查: +sandbox 自有的 SID、ACL、token、文件与锁常量和布局均已在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `fileapi.h`)验证,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 交叉检查。共享的 process、stdio 与 Job ABI 由 [`@deepseek-ai/dsh-win32-process`](../../subprocess/win32-process/README.md#header-verification) 归属并验证。 ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index d9bb79be51..1dddea0df5 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md -README.md: 0005416bdfac6101090a3dc87defd71e15ec7537 -README.zh.md: 2c505ea5a1ec2fe2a930eca035b8a64ca3d4ba4f +README.md: fcc6ad9cb5ca99ac55c817ef796c20751efffacc +README.zh.md: fbf37823b409f743818b1425192070a5c90f3932 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 0005416bdf..fcc6ad9cb5 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -14,6 +14,16 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. +## Header verification + +The process, stdio, and Job constants, signatures, and layouts are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe +``` + +The Koffi struct definitions also assert their sizes at module load, so a header or layout mismatch fails before native process creation. + ## Model Experience ### Process primitives diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 2c505ea5a1..fbf37823b4 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -14,6 +14,16 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 +## 头部验证 + +process、stdio 与 Job 的常量、签名和布局由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe +``` + +Koffi 结构体定义还会在模块加载时断言自身大小,因此头文件或布局不匹配会在创建 native process 前失败。 + ## Model Experience ### 进程原语 From 458ba498151914fb5feba8d8491ac14918060b6b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 19:28:05 +0800 Subject: [PATCH 21/23] docs(win32-process): narrow ABI verification claims --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 -- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 -- packages/subprocess/win32-process/README.i18n.yaml | 4 ++-- packages/subprocess/win32-process/README.md | 4 ++-- packages/subprocess/win32-process/README.zh.md | 4 ++-- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 10be54b7bb..7a44094d6f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 91172cc0b2fcab1daceb75f7c02f3eecc679bab1 -README.zh.md: 0e8435e3d1a27a2868f97d6af4ce9e66953e8a26 +README.md: 2cf79c8eede0943630f79bea717c9e072226fa7f +README.zh.md: 690b5d10ecbeae0192dc98095c785ab64030e9f6 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 91172cc0b2..2cf79c8eed 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -68,8 +68,6 @@ The sandbox-owned SID, ACL, token, file, and lock constants and layouts were ver g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe ``` -The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory. - ## Verified boundaries (inherent to restricted tokens, not this port) - **Everyone grants remain ambient write authority.** Everyone must stay in both restricting lists: removing it breaks early DLL initialization and CNG. An external NTFS object whose normal DACL grants Everyone a requested write right therefore clears both access checks and stays writable under both modes. The real runner suite provisions an external `Everyone:Modify` directory and pins that behavior; the provider reports `enforcement: 'partial'` so callers can reject or surface the weaker boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 0e8435e3d1..690b5d10ec 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -70,8 +70,6 @@ sandbox 自有的 SID、ACL、token、文件与锁常量和布局均已在开发 g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe ``` -koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。 - ## 已验证边界(受限令牌固有,非本移植引入) - **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中:移除它会破坏早期 DLL 初始化与 CNG。因此,如果外部 NTFS 对象的正常 DACL 向 Everyone 授予所请求的写权限,它就会同时通过两次访问检查,并在两种模式下保持可写。真实 runner 套件配置一个外部 `Everyone:Modify` 目录并钉住该行为;提供方报告 `enforcement: 'partial'`,使调用方能够拒绝或向上暴露这项较弱的边界。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 1dddea0df5..c89fd17fb3 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md -README.md: fcc6ad9cb5ca99ac55c817ef796c20751efffacc -README.zh.md: fbf37823b409f743818b1425192070a5c90f3932 +README.md: 208a31741098c5c4d76d846b3a1345b24b6fc135 +README.zh.md: e5da30f30c447c4f450af3062f1aa11675678394 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index fcc6ad9cb5..208a317410 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -16,13 +16,13 @@ The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child polic ## Header verification -The process, stdio, and Job constants, signatures, and layouts are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): +The process, stdio, and Job constants plus selected structure sizes and offsets are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -The Koffi struct definitions also assert their sizes at module load, so a header or layout mismatch fails before native process creation. +The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe remains the evidence for the other recorded offsets and constants. ## Model Experience diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index fbf37823b4..e5da30f30c 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -16,13 +16,13 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 ## 头部验证 -process、stdio 与 Job 的常量、签名和布局由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: +process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -Koffi 结构体定义还会在模块加载时断言自身大小,因此头文件或布局不匹配会在创建 native process 前失败。 +Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小;其余已记录偏移和常量由该探针提供证据。 ## Model Experience From a5368680ae957e2402aa06ba3d0189890a233841 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 19:53:56 +0800 Subject: [PATCH 22/23] docs(win32-process): keep localized header link valid --- packages/subprocess/win32-process/README.i18n.yaml | 2 +- packages/subprocess/win32-process/README.zh.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index c89fd17fb3..d5743ba165 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md README.md: 208a31741098c5c4d76d846b3a1345b24b6fc135 -README.zh.md: e5da30f30c447c4f450af3062f1aa11675678394 +README.zh.md: 3c403efa8cdf389539663e79d95b766fb8ba0fcf diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index e5da30f30c..3c403efa8c 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -14,6 +14,8 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 + + ## 头部验证 process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: From 85b8484a95cee01336c3d56c9bcca480b94e62f3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 20:13:01 +0800 Subject: [PATCH 23/23] test(sandbox): remove stale export assertion --- packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 760370b24f..5dab1058c2 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -75,12 +75,6 @@ describe('getTempPath', () => { }) }) -describe('public error export', () => { - it('keeps the sandbox Win32 error type', () => { - expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error) - }) -}) - describe('sandbox pointer handling', () => { it('isInvalidHandle treats NULL as failure', () => { expect(isInvalidHandle(null)).toBe(true)