mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(win32-process): share native process primitives
This commit is contained in:
+6
@@ -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
|
||||
@@ -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.
|
||||
+35
@@ -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。
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
<a id="the-confinement-runner"></a>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-win32-process": "workspace:^",
|
||||
"koffi": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof koffi.pointer>
|
||||
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<typeof koffi.pointer>
|
||||
|
||||
/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */
|
||||
export interface StartupInfoInput {
|
||||
cb: number
|
||||
dwFlags: number
|
||||
hStdInput: NativePtr
|
||||
hStdOutput: NativePtr
|
||||
hStdError: NativePtr
|
||||
}
|
||||
|
||||
/** Decoded PROCESS_INFORMATION (layout verified: size 24). */
|
||||
export interface ProcessInfoOutput {
|
||||
hProcess: NativePtr | null
|
||||
hThread: NativePtr | null
|
||||
dwProcessId: number
|
||||
dwThreadId: number
|
||||
}
|
||||
|
||||
/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */
|
||||
export interface Win32Bindings {
|
||||
// ---- process / token handles --------------------------------------------
|
||||
openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr
|
||||
openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number
|
||||
closeHandle(handle: NativePtr): number
|
||||
// ---- errors / diagnostics ------------------------------------------------
|
||||
getLastError(): number
|
||||
formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number
|
||||
// ---- memory --------------------------------------------------------------
|
||||
localAlloc(flags: number, bytes: number): NativePtr
|
||||
localFree(memory: NativePtr): NativePtr
|
||||
// ---- SIDs ----------------------------------------------------------------
|
||||
convertStringSidToSidW(stringSid: string, sid: NativePtr): number
|
||||
createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number
|
||||
isValidSid(sid: NativePtr): number
|
||||
getLengthSid(sid: NativePtr): number
|
||||
copySid(length: number, destination: NativePtr, source: NativePtr): number
|
||||
// ---- token information ---------------------------------------------------
|
||||
getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number
|
||||
setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number
|
||||
// ---- restricted token ----------------------------------------------------
|
||||
createRestrictedToken(
|
||||
existing: NativePtr, flags: number,
|
||||
disableCount: number, disableSids: null,
|
||||
deletePrivilegeCount: number, privilegesToDelete: null,
|
||||
restrictCount: number, restrictingSids: Buffer,
|
||||
newToken: NativePtr,
|
||||
): number
|
||||
// ---- ACL editing ---------------------------------------------------------
|
||||
setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number
|
||||
setNamedSecurityInfoW(
|
||||
path: string, objectType: number, information: number,
|
||||
owner: null, group: null, dacl: NativePtr | null, sacl: null,
|
||||
): number
|
||||
getNamedSecurityInfoW(
|
||||
path: string, objectType: number, information: number,
|
||||
owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr,
|
||||
): number
|
||||
// ---- environment / io ----------------------------------------------------
|
||||
getTempPathW(length: number, buffer: Buffer): number
|
||||
createFileW(
|
||||
fileName: string, desiredAccess: number, shareMode: number, attributes: null,
|
||||
creationDisposition: number, flagsAndAttributes: number, templateFile: null,
|
||||
): NativePtr
|
||||
lockFileEx(file: NativePtr, flags: number, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number
|
||||
unlockFileEx(file: NativePtr, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number
|
||||
createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number
|
||||
setHandleInformation(handle: NativePtr, mask: number, flags: number): number
|
||||
createProcessAsUserW(
|
||||
token: NativePtr, applicationName: null, commandLine: string,
|
||||
processAttributes: null, threadAttributes: null,
|
||||
inheritHandles: number, creationFlags: number, environment: null,
|
||||
currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr,
|
||||
): number
|
||||
setEnvironmentVariableW(name: string, value: string): number
|
||||
readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number
|
||||
peekNamedPipe(
|
||||
pipe: NativePtr, buffer: null, size: number,
|
||||
bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr,
|
||||
): number
|
||||
waitForSingleObject(handle: NativePtr, milliseconds: number): number
|
||||
getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number
|
||||
resumeThread(thread: NativePtr): number
|
||||
// ---- job object (runner kill-on-close) -----------------------------------
|
||||
createJobObjectW(attributes: null, name: null): NativePtr
|
||||
setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number
|
||||
assignProcessToJobObject(job: NativePtr, process: NativePtr): number
|
||||
// Terminate a suspended child that could not be placed in the kill-on-close
|
||||
// job — closing handles alone would leave it hanging forever.
|
||||
terminateProcess(process: NativePtr, exitCode: number): number
|
||||
// ---- console -------------------------------------------------------------
|
||||
// HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h):
|
||||
// the runner survives console Ctrl+C so the child handles its own and the
|
||||
// runner can clean up grants after the child exits.
|
||||
setConsoleCtrlHandler(handler: null, add: number): number
|
||||
getStdHandle(stdHandle: number): NativePtr
|
||||
}
|
||||
|
||||
const PVOID: Ptr = koffi.pointer('void')
|
||||
const PPVOID: Ptr = koffi.pointer(PVOID)
|
||||
|
||||
/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */
|
||||
export const STARTUPINFOW = koffi.struct('STARTUPINFOW', {
|
||||
cb: 'uint32',
|
||||
lpReserved: 'str16',
|
||||
lpDesktop: 'str16',
|
||||
lpTitle: 'str16',
|
||||
dwX: 'uint32',
|
||||
dwY: 'uint32',
|
||||
dwXSize: 'uint32',
|
||||
dwYSize: 'uint32',
|
||||
dwXCountChars: 'uint32',
|
||||
dwYCountChars: 'uint32',
|
||||
dwFillAttribute: 'uint32',
|
||||
dwFlags: 'uint32',
|
||||
wShowWindow: 'uint16',
|
||||
cbReserved2: 'uint16',
|
||||
lpReserved2: koffi.pointer('uint8'),
|
||||
hStdInput: PVOID,
|
||||
hStdOutput: PVOID,
|
||||
hStdError: PVOID,
|
||||
})
|
||||
|
||||
/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */
|
||||
export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', {
|
||||
hProcess: PVOID,
|
||||
hThread: PVOID,
|
||||
dwProcessId: 'uint32',
|
||||
dwThreadId: 'uint32',
|
||||
})
|
||||
|
||||
/* 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<typeof koffi.load>, name: string, result: Ptr | string, args: Array<Ptr | string>): 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<Win32Bindings> {
|
||||
return Promise.resolve(bindings())
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's
|
||||
* server-side per-session grant materializes ACEs inside the synchronous
|
||||
* `confine()` call, which cannot await. Same cached table as {@link win32}
|
||||
* (the underlying koffi loads are synchronous; the async wrapper exists for
|
||||
* the runner's await-shaped call sites).
|
||||
* @returns the cached binding table.
|
||||
* 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))
|
||||
}
|
||||
|
||||
@@ -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<number> | undefined
|
||||
let settlement: Promise<AclSandboxChildResult> | 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<number> | undefined
|
||||
let settlement: Promise<AclSandboxChildResult> | 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<unknown>(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<Buffer>).value,
|
||||
stderr: (drains[1] as PromiseFulfilledResult<Buffer>).value,
|
||||
exitCode,
|
||||
}
|
||||
})()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Buffer> {
|
||||
const chunks: Buffer[] = []
|
||||
for (;;) {
|
||||
const bytesReadSlot = allocUint32()
|
||||
const totalAvailSlot = allocUint32()
|
||||
const leftThisMessageSlot = allocUint32()
|
||||
const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot)
|
||||
if (peeked === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF
|
||||
throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`)
|
||||
}
|
||||
const available = decodeUint32(totalAvailSlot)
|
||||
if (available > 0) {
|
||||
const chunk = Buffer.alloc(available)
|
||||
const readSlot = allocUint32()
|
||||
if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) {
|
||||
throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`)
|
||||
}
|
||||
chunks.push(chunk.subarray(0, decodeUint32(readSlot)))
|
||||
}
|
||||
// Small backoff instead of setImmediate: a bare next-tick would busy-poll
|
||||
// the pipe at full event-loop speed while the child produces no output.
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 1))
|
||||
}
|
||||
api.closeHandle(handle)
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for process exit and return its exit code. Call only after both drains
|
||||
* have resolved — the drains finish when the child closed its pipe ends, i.e.
|
||||
* the child has already exited, so this wait returns immediately. Calling it
|
||||
* earlier would block the event loop and starve the drains (the pipe-buffer
|
||||
* deadlock the POC comments warn about).
|
||||
* @param api - the binding table.
|
||||
* @param process - the child process handle (closed when done).
|
||||
* @returns the child's exit code.
|
||||
*/
|
||||
export function waitForExit(api: Win32Bindings, process: NativePtr): number {
|
||||
const waitResult = api.waitForSingleObject(process, abi.INFINITE)
|
||||
if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject')
|
||||
const exitCodeSlot = allocUint32()
|
||||
if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess')
|
||||
api.closeHandle(process)
|
||||
return decodeUint32(exitCodeSlot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at
|
||||
* LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout
|
||||
* verified by abi-probe.cpp). When the caller dies with the job handle open,
|
||||
* Windows terminates every process in the job — the orphan-child backstop.
|
||||
* The caller keeps the returned handle open for the child's lifetime.
|
||||
*/
|
||||
function createKillOnCloseJob(api: Win32Bindings): NativePtr {
|
||||
const job = api.createJobObjectW(null, null)
|
||||
if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW')
|
||||
const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE)
|
||||
information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET)
|
||||
if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'SetInformationJobObject', win32Code)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */
|
||||
export interface SpawnedInherited {
|
||||
pid: number
|
||||
process: NativePtr
|
||||
/** Kill-on-close job the child was placed in; caller closes it after the child exits. */
|
||||
job: NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a process under the restricted token whose stdio passes straight
|
||||
* through to the caller's pipes. This is the runner shape: the harness spawns
|
||||
* the runner with piped stdio, and the runner's confined child writes to
|
||||
* those same pipes.
|
||||
*
|
||||
* Node clears the inheritability of its stdio handles at startup
|
||||
* (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit
|
||||
* bit around the call (libuv instead duplicates the handles; re-enabling is
|
||||
* equivalent here and cheaper) and pass them explicitly via
|
||||
* STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles
|
||||
* ("The handle is invalid", verified the hard way). The child starts
|
||||
* suspended so it can be assigned to a kill-on-close job before it runs.
|
||||
* @param api - the binding table.
|
||||
* @param token - the restricted token the child runs under.
|
||||
* 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/win32-process"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 <Windows.h>
|
||||
#include <sddl.h>
|
||||
#include <AclAPI.h>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 运行器),以及决定塑造该进程的每一项默认值。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof koffi.pointer>
|
||||
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<typeof koffi.load>
|
||||
/** Token and security APIs. */
|
||||
readonly advapi32: ReturnType<typeof koffi.load>
|
||||
/** Bind one stdcall function from a loaded Win32 library. */
|
||||
readonly bind: (
|
||||
library: ReturnType<typeof koffi.load>,
|
||||
name: string,
|
||||
result: Ptr | string,
|
||||
args: Array<Ptr | string>,
|
||||
) => 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<typeof koffi.load>,
|
||||
name: string,
|
||||
result: Ptr | string,
|
||||
args: Array<Ptr | string>,
|
||||
): 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<Extension extends object>(
|
||||
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))
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 */
|
||||
@@ -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<NativePtr>): 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<NativePtr>, 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<NativePtr>): 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<NativePtr>()
|
||||
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<Buffer> {
|
||||
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<void>(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<typeof decodeProcessInfo>
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+96
-83
@@ -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<typeof vi.fn> } {
|
||||
function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } {
|
||||
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<typeof vi.fn> } {
|
||||
function resumeFailureApi(): {
|
||||
api: Win32ProcessBindings
|
||||
closed: bigint[]
|
||||
closeHandle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const closed: bigint[] = []
|
||||
let std = 50n
|
||||
const closeHandle = vi.fn((handle: NativePtr) => {
|
||||
closed.push(handle)
|
||||
return 1
|
||||
})
|
||||
const 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<typeof vi.fn>
|
||||
terminateProcess: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const api = baseApi as JobFailureApi
|
||||
api.assignProcessToJobObject = vi.fn(() => 0)
|
||||
api.terminateProcess = vi.fn(() => 1)
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('AssignProcessToJobObject')
|
||||
expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1)
|
||||
// thread, process, job — and the child is already dead before they close.
|
||||
expect(closeHandle).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTempPath buffer defense', () => {
|
||||
it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => {
|
||||
const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer
|
||||
expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u)
|
||||
})
|
||||
})
|
||||
|
||||
/** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */
|
||||
function pipeOkApi(overrides: Partial<Win32Bindings> = {}): {
|
||||
api: Win32Bindings
|
||||
function pipeOkApi(overrides: Partial<Win32ProcessBindings> = {}): {
|
||||
api: Win32ProcessBindings
|
||||
closed: bigint[]
|
||||
closeHandle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
@@ -168,18 +146,22 @@ function pipeOkApi(overrides: Partial<Win32Bindings> = {}): {
|
||||
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<Win32Bindings> = {}): {
|
||||
api: Win32Bindings
|
||||
function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
|
||||
api: Win32ProcessBindings
|
||||
closed: bigint[]
|
||||
closeHandle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Win32ProcessBindings> = {}): {
|
||||
api: Win32ProcessBindings
|
||||
events: string[]
|
||||
createProcessAsUserW: ReturnType<typeof vi.fn>
|
||||
assignProcessToJobObject: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#include <Windows.h>
|
||||
#include <cstdio>
|
||||
#include <cstddef>
|
||||
|
||||
#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr))
|
||||
|
||||
int wmain()
|
||||
{
|
||||
P(sizeof(void*));
|
||||
P(sizeof(HANDLE));
|
||||
P(sizeof(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;
|
||||
}
|
||||
Generated
+16
@@ -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':
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user