fix(win32-process): close PR1 review gaps

This commit is contained in:
pku-xht 2026-08-19 05:03:59 +08:00
parent ab494bfdca
commit 4a722de4fa
26 changed files with 435 additions and 80 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md
2026-08-19-shared-win32-process-primitives.md: ab23b02dfb4e937891b26b009900696ada3fa3c0
2026-08-19-shared-win32-process-primitives.zh.md: e8686d9f4d1ac2d05c0eecf025ada19d491e50d2
2026-08-19-shared-win32-process-primitives.md: 58bbd5a2ae44caf85dfca144d99efabb063243e2
2026-08-19-shared-win32-process-primitives.zh.md: 5c4e63979412fbb617094ad1745f4a8318b49237

View file

@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p
## Decision
`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations.
`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations.
The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary.
Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Successful inherited-stdio creation returns the process plus kill-on-close Job after the child is suspended, assigned to the Job, and resumed; assignment failure terminates the suspended child before releasing its handles. The sandbox owns returned process, pipe, and Job handles until wait or disposal.
Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so a successfully created suspended child is already Job-owned before resume; attribute, creation, or resume failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal.
The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK.
## Verification
The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended Job assignment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile the header probe and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal.
The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic suspended Job attachment before resume, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal.
## Alternatives considered

View file

@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p
## Decision
`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。
`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW`、`STARTUPINFOEXW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。
Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。
每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建成功时,child 已 suspended、指派给 Job 并 resume,随后把 process 与 kill-on-close Job 返回给 sandbox;指派失败会先终止 suspended child,再释放其 handles。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。
每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此成功创建的 suspended child 在 resume 前已经归属 Job;attribute、创建或 resume 失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。
该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。
## Verification
shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的 suspended Job 指派、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。
shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、resume 前的原子 suspended Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。
## Alternatives considered

View file

@ -488,6 +488,22 @@ jobs:
shell: pwsh
run: pnpm install --frozen-lockfile
- name: Compile and run Win32 header ABI probes
shell: pwsh
run: |
$probeRoot = Join-Path $env:RUNNER_TEMP 'dsh-win32-abi-probes'
New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null
$processProbe = Join-Path $probeRoot 'win32-process.exe'
$sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe'
g++ -std=c++20 -municode -O2 -o $processProbe packages/subprocess/win32-process/verify/abi-probe.cpp
if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe compilation failed' }
& $processProbe
if ($LASTEXITCODE -ne 0) { throw 'win32-process ABI probe failed' }
g++ -std=c++20 -municode -O2 -o $sandboxProbe packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp -ladvapi32
if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe compilation failed' }
& $sandboxProbe
if ($LASTEXITCODE -ne 0) { throw 'sandbox-windows-acl ABI probe failed' }
- name: Run complete native Windows gate inventory
shell: pwsh
run: pnpm run check:ci:windows-complete

View file

@ -32,6 +32,7 @@ const WORKSPACE_CLOSURE = [
// consumer resolves it like any other @deepseek-ai peer (koffi arrives
// from the registry).
'packages/sandbox/sandbox-windows-acl',
'packages/subprocess/win32-process',
'packages/sandbox/sandbox',
'packages/core/session',
'packages/core/scope',

View file

@ -55,6 +55,7 @@ import * as abi from './win32-abi.ts'
export { AclWriteGrant } from './grant.ts'
export { assertTempRootOutsideWorkspace } from './path-boundary.ts'
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
export { quoteArg, Win32Error } from '@deepseek-ai/dsh-win32-process'
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
export interface AclSandboxOptions {

View file

@ -6,10 +6,10 @@
* acl.spec.ts and probe.spec.ts (win32 only).
*/
import { Win32Error } from '@deepseek-ai/dsh-win32-process'
import { describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { Win32Error, quoteArg } from '../src/index.ts'
import {
allocBytes, decodePtrAt, getTempPath,
isInvalidHandle, sameSidAt,
@ -75,6 +75,13 @@ describe('getTempPath', () => {
})
})
describe('public compatibility exports', () => {
it('keeps the sandbox Win32 error and quoting API', () => {
expect(new Win32Error('Probe', 5)).toBeInstanceOf(Error)
expect(quoteArg('a b')).toBe('"a b"')
})
})
describe('sandbox pointer handling', () => {
it('isInvalidHandle treats NULL as failure', () => {
expect(isInvalidHandle(null)).toBe(true)

View file

@ -145,7 +145,15 @@ function happyStubs(): HappyStubs {
})
const createJobObjectW = vi.fn(() => fresh())
const setInformationJobObject = vi.fn(() => 1)
const assignProcessToJobObject = vi.fn(() => 1)
const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
if (list === null) {
koffi.encode(size, 'size_t', 64)
return 0
}
return 1
})
const updateProcThreadAttribute = vi.fn(() => 1)
const deleteProcThreadAttributeList = vi.fn()
const resumeThread = vi.fn(() => 0)
const getStdHandle = vi.fn(() => fresh())
const localFree = vi.fn(() => 0n)
@ -160,7 +168,8 @@ function happyStubs(): HappyStubs {
getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken,
setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW,
peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW,
setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle,
setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute,
deleteProcThreadAttributeList, resumeThread, getStdHandle,
localFree, closeHandle, getLastError, formatMessageW,
} as unknown as Win32Bindings
return {

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/README.md
README.md: ba74f0d2ed2251c3527259b571663abf5bf740a2
README.zh.md: fefc13d49b94ddba2e697d3481b4b991531540a8
README.md: 56d6c04af92fa07673e3f8881bf20e47358fd001
README.zh.md: 8b7db95e0196dbc98a67657478e4e242b4f7bca9

View file

@ -8,7 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s
|---|---|---|
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary |
| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal |
| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, Job assignment, waits, and handle cleanup |
| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, atomic Job attachment, waits, and handle cleanup |
The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.

View file

@ -8,7 +8,7 @@
|---|---|---|
| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 |
| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) |
| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、Job 指派、wait 与句柄清理的唯一 Koffi owner |
| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、原子 Job 附加、wait 与句柄清理的唯一 Koffi owner |
即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md
README.md: a18b1b8167e3ea76d61f022f4aa3ea827546d93f
README.zh.md: 262300f5da48aeed4fe7c05d970d498147921c49
README.md: 53064791de5375cd05008509db4f9d27beec3dc3
README.zh.md: f5c6bc8334908241b5e6a2332f79e3d3808f6469

View file

@ -6,10 +6,10 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the
## Behavior
- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries.
- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries.
- **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup.
- **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime.
- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, restores the parent handle flags, and resumes the child. Creation, assignment, or resume failure closes every owned resource; assignment failure terminates the still-suspended child before releasing its process and thread handles.
- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, attaches that Job through `STARTUPINFOEXW`, creates the restricted child suspended and already Job-owned, restores the parent handle flags, and resumes the child. Attribute setup, creation, or resume failure closes every owned resource; no successful process creation can leave an unowned suspended child.
- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining and frees it before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal.
The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.

View file

@ -6,10 +6,10 @@
## Behavior
- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。
- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。
- **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。
- **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。
- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,将其指派给 Job,恢复父进程句柄标志,再 resume child。创建、指派或 resume 失败都会关闭全部已拥有资源;指派失败会先终止仍 suspended 的 child,再释放其 process 与 thread handles。
- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,通过 `STARTUPINFOEXW` 附加该 Job,以 suspended 且已经归属 Job 的状态创建 restricted child,恢复父进程句柄标志,再 resume child。attribute 设置、创建或 resume 失败都会关闭全部已拥有资源;成功创建进程后不会留下无 owner 的 suspended child。
- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,并在关闭管道读取句柄前释放这些槽;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。
Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。

View file

@ -8,6 +8,10 @@ export const HANDLE_FLAG_INHERIT = 0x1
export const INFINITE = 0xFFFFFFFF
/** CreateProcess flag that prevents user code from running before resume. */
export const CREATE_SUSPENDED = 0x4
/** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */
export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000
/** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */
export const PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D
/** GetStdHandle selector for standard input. */
export const STD_INPUT_HANDLE = -10
/** GetStdHandle selector for standard output. */
@ -34,5 +38,9 @@ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144
export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16
/** x64 STARTUPINFOW byte size verified by the native probe. */
export const STARTUPINFOW_SIZE = 104
/** x64 STARTUPINFOEXW byte size verified by the native probe. */
export const STARTUPINFOEXW_SIZE = 112
/** x64 pointer and HANDLE byte size. */
export const POINTER_SIZE = 8
/** x64 PROCESS_INFORMATION byte size verified by the native probe. */
export const PROCESS_INFORMATION_SIZE = 24

View file

@ -81,6 +81,22 @@ export interface Win32ProcessBindings {
startupInfo: NativePtr,
processInfo: NativePtr,
): number
initializeProcThreadAttributeList(
attributeList: Buffer | null,
attributeCount: number,
flags: number,
size: NativePtr,
): number
updateProcThreadAttribute(
attributeList: Buffer,
flags: number,
attribute: number,
value: NativePtr,
size: number,
previousValue: null,
returnSize: null,
): number
deleteProcThreadAttributeList(attributeList: Buffer): void
readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number
peekNamedPipe(
pipe: NativePtr,
@ -95,7 +111,6 @@ export interface Win32ProcessBindings {
resumeThread(thread: NativePtr): number
createJobObjectW(attributes: null, name: null): NativePtr
setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number
assignProcessToJobObject(job: NativePtr, process: NativePtr): number
terminateProcess(process: NativePtr, exitCode: number): number
getStdHandle(stdHandle: number): NativePtr
}
@ -241,6 +256,13 @@ function bindings(): Win32ProcessBindings {
PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16',
koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION),
]),
initializeProcThreadAttributeList: bind(kernel32, 'InitializeProcThreadAttributeList', 'int', [
PVOID, 'uint32', 'uint32', koffi.pointer('size_t'),
]),
updateProcThreadAttribute: bind(kernel32, 'UpdateProcThreadAttribute', 'int', [
PVOID, 'uint32', 'size_t', PVOID, 'size_t', PVOID, PVOID,
]),
deleteProcThreadAttributeList: bind(kernel32, 'DeleteProcThreadAttributeList', 'void', [PVOID]),
readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]),
peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [
PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'),
@ -250,7 +272,6 @@ function bindings(): Win32ProcessBindings {
resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]),
createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']),
setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']),
assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]),
terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']),
getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']),
} as unknown as Win32ProcessBindings

View file

@ -19,6 +19,7 @@ export type {
export {
closeHandleChecked,
drainPipe,
quoteArg,
spawnInheritedJobProcess,
spawnPipedProcess,
waitForProcessExit,

View file

@ -0,0 +1,124 @@
/** Package-private STARTUPINFOEXW ownership for atomic Job attachment. */
import koffi from 'koffi'
import * as abi from './abi.ts'
import { STARTUPINFOW, throwWin32 } from './ffi.ts'
import type { NativePtr, StartupInfoInput, Win32ProcessBindings } from './ffi.ts'
type Ptr = ReturnType<typeof koffi.pointer>
const PVOID: Ptr = koffi.pointer('void')
const STARTUPINFOEXW = koffi.struct('DSH_STARTUPINFOEXW', {
StartupInfo: STARTUPINFOW,
lpAttributeList: PVOID,
})
/* v8 ignore start -- the native header probe pins this x64 layout. */
if (STARTUPINFOEXW.size !== abi.STARTUPINFOEXW_SIZE) {
throw new Error(`STARTUPINFOEXW layout mismatch: koffi computed ${STARTUPINFOEXW.size}, expected ${abi.STARTUPINFOEXW_SIZE}`)
}
/* v8 ignore stop */
/** One extended startup record whose attribute list remains valid through CreateProcess. */
export interface JobStartupInfo {
/** STARTUPINFOEXW pointer passed to CreateProcessAsUserW. */
readonly pointer: NativePtr
/** Release the initialized process attribute list after CreateProcessAsUserW returns. */
dispose(): void
}
function queryAttributeListSize(api: Win32ProcessBindings): number {
const sizeSlot = koffi.alloc('size_t', 1) as NativePtr
try {
api.initializeProcThreadAttributeList(null, 1, 0, sizeSlot)
const attributeBytes = koffi.decode(sizeSlot, 'size_t') as number
if (attributeBytes === 0) {
throwWin32(
api,
'InitializeProcThreadAttributeList',
api.getLastError(),
'process-attribute size query',
)
}
return attributeBytes
} finally {
koffi.free(sizeSlot)
}
}
/**
* Build a STARTUPINFOEXW that assigns the restricted child to `job` during creation.
* @param api - active binding table.
* @param fields - inherited stdio fields for the nested STARTUPINFOW.
* @param job - caller-owned Job attached before any child thread exists.
* @returns extended startup pointer and its post-CreateProcess disposer.
*/
export function createJobStartupInfo(
api: Win32ProcessBindings,
fields: Omit<StartupInfoInput, 'cb'>,
job: NativePtr,
): JobStartupInfo {
const attributeList = Buffer.alloc(queryAttributeListSize(api))
const sizeSlot = koffi.alloc('size_t', 1) as NativePtr
let initialized = false
let jobList: NativePtr | undefined
try {
koffi.encode(sizeSlot, 'size_t', attributeList.length)
if (api.initializeProcThreadAttributeList(attributeList, 1, 0, sizeSlot) === 0) {
throwWin32(
api,
'InitializeProcThreadAttributeList',
api.getLastError(),
'process-attribute initialization',
)
}
initialized = true
jobList = koffi.alloc(PVOID, 1) as NativePtr
koffi.encode(jobList, PVOID, job)
if (api.updateProcThreadAttribute(
attributeList,
0,
abi.PROC_THREAD_ATTRIBUTE_JOB_LIST,
jobList,
abi.POINTER_SIZE,
null,
null,
) === 0) {
throwWin32(
api,
'UpdateProcThreadAttribute',
api.getLastError(),
'PROC_THREAD_ATTRIBUTE_JOB_LIST',
)
}
const pointer = koffi.alloc(STARTUPINFOEXW, 1) as NativePtr
try {
koffi.encode(pointer, STARTUPINFOEXW, {
StartupInfo: { ...fields, cb: abi.STARTUPINFOEXW_SIZE },
lpAttributeList: attributeList,
})
} catch (error) {
/* v8 ignore start -- staging a STARTUPINFOEXW encode failure requires replacing Koffi's encoder. */
koffi.free(pointer)
throw error
/* v8 ignore stop */
}
return {
pointer,
dispose: () => {
try {
api.deleteProcThreadAttributeList(attributeList)
} finally {
koffi.free(jobList)
koffi.free(pointer)
}
},
}
} catch (error) {
if (initialized) api.deleteProcThreadAttributeList(attributeList)
if (jobList !== undefined) koffi.free(jobList)
throw error
} finally {
koffi.free(sizeSlot)
}
}

View file

@ -15,6 +15,7 @@ import {
throwLastError,
throwWin32,
} from './ffi.ts'
import { createJobStartupInfo } from './job-attribute.ts'
import type { NativePtr, Win32ProcessBindings } from './ffi.ts'
/**
@ -77,7 +78,7 @@ export interface SpawnedPipedProcess {
stderrRead: NativePtr
}
/** Suspended-created child assigned to one caller-owned kill-on-close Job before resume. */
/** Suspended-created child atomically attached to one caller-owned kill-on-close Job. */
export interface SpawnedJobProcess {
/** Direct child process id. */
pid: number
@ -310,7 +311,7 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr {
}
/**
* Spawn suspended inside a kill-on-close Job, then resume.
* Spawn suspended and atomically attached to a kill-on-close Job, then resume.
* @param api - active binding table.
* @param options - command, cwd, args, and restricted primary token.
* @returns caller-owned process and Job handles after successful resume.
@ -331,7 +332,6 @@ export function spawnInheritedJobProcess(
const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout')
const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr')
const enabled: NativePtr[] = []
let startupInfo: NativePtr | undefined
let processInfo: NativePtr | undefined
let created = 0
let createFailureCode = 0
@ -346,27 +346,28 @@ export function spawnInheritedJobProcess(
}
enabled.push(handle)
}
startupInfo = allocStartupInfo()
encodeStartupInfo(startupInfo, {
cb: abi.STARTUPINFOW_SIZE,
const startupInfo = createJobStartupInfo(api, {
dwFlags: abi.STARTF_USESTDHANDLES,
hStdInput: stdIn,
hStdOutput: stdOut,
hStdError: stdErr,
})
processInfo = allocProcessInfo()
created = createRestrictedProcess(
api,
options,
buildCommandLine(options.command, options.args),
abi.CREATE_SUSPENDED,
startupInfo,
processInfo,
)
if (created === 0) createFailureCode = api.getLastError()
}, job)
try {
processInfo = allocProcessInfo()
created = createRestrictedProcess(
api,
options,
buildCommandLine(options.command, options.args),
abi.CREATE_SUSPENDED | abi.EXTENDED_STARTUPINFO_PRESENT,
startupInfo.pointer,
processInfo,
)
if (created === 0) createFailureCode = api.getLastError()
} finally {
startupInfo.dispose()
}
} catch (error) {
freeNative(processInfo)
freeNative(startupInfo)
api.closeHandle(job)
throw error
} finally {
@ -374,7 +375,6 @@ export function spawnInheritedJobProcess(
}
if (created === 0) {
freeNative(processInfo)
freeNative(startupInfo)
api.closeHandle(job)
throwWin32(
api,
@ -388,23 +388,13 @@ export function spawnInheritedJobProcess(
info = decodeProcessInfo(processInfo)
} finally {
freeNative(processInfo)
freeNative(startupInfo)
}
if (info.hProcess === null || info.hThread === null) {
if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1)
api.closeHandle(job)
closeBestEffort(api, info.hThread)
closeBestEffort(api, info.hProcess)
api.closeHandle(job)
throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`)
}
if (api.assignProcessToJobObject(job, info.hProcess) === 0) {
const win32Code = api.getLastError()
api.terminateProcess(info.hProcess, 1)
closeBestEffort(api, info.hThread)
closeBestEffort(api, info.hProcess)
api.closeHandle(job)
throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`)
}
if (api.resumeThread(info.hThread) === 0xFFFFFFFF) {
const win32Code = api.getLastError()
closeBestEffort(api, info.hThread)

View file

@ -0,0 +1,65 @@
import koffi from 'koffi'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createJobStartupInfo } from '../src/job-attribute.ts'
import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts'
afterEach(() => {
vi.restoreAllMocks()
})
function bindings(): {
api: Win32ProcessBindings
deleteProcThreadAttributeList: ReturnType<typeof vi.fn>
} {
const deleteProcThreadAttributeList = vi.fn()
const api = {
initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
if (list === null) {
koffi.encode(size, 'size_t', 64)
return 0
}
return 1
}),
updateProcThreadAttribute: vi.fn(() => 1),
deleteProcThreadAttributeList,
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
} as unknown as Win32ProcessBindings
return { api, deleteProcThreadAttributeList }
}
const fields = {
dwFlags: 0x100,
hStdInput: 1n as NativePtr,
hStdOutput: 2n as NativePtr,
hStdError: 3n as NativePtr,
}
describe('createJobStartupInfo allocation cleanup', () => {
it('frees the size slot when attribute-list buffer allocation throws', () => {
const { api, deleteProcThreadAttributeList } = bindings()
const free = vi.spyOn(koffi, 'free')
vi.spyOn(Buffer, 'alloc').mockImplementationOnce(() => { throw new Error('buffer allocation failed') })
expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('buffer allocation failed')
expect(free).toHaveBeenCalledOnce()
expect(deleteProcThreadAttributeList).not.toHaveBeenCalled()
})
it('deletes the initialized list and frees the Job value when attachment fails', () => {
const { api, deleteProcThreadAttributeList } = bindings()
api.updateProcThreadAttribute = vi.fn(() => 0)
const free = vi.spyOn(koffi, 'free')
expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('PROC_THREAD_ATTRIBUTE_JOB_LIST')
expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce()
expect(free).toHaveBeenCalledTimes(3)
})
it('frees every native allocation after the caller disposes the startup record', () => {
const { api, deleteProcThreadAttributeList } = bindings()
const free = vi.spyOn(koffi, 'free')
const startup = createJobStartupInfo(api, fields, 50n as NativePtr)
startup.dispose()
expect(free).toHaveBeenCalledTimes(4)
expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce()
})
})

View file

@ -20,11 +20,21 @@ afterEach(() => {
describe('spawnInheritedJobProcess allocation cleanup', () => {
it('frees startup info when process-info allocation throws', () => {
const deleteProcThreadAttributeList = vi.fn()
const api = {
createJobObjectW: vi.fn(() => 50n),
setInformationJobObject: vi.fn(() => 1),
getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)),
setHandleInformation: vi.fn(() => 1),
initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
if (list === null) {
koffi.encode(size, 'size_t', 64)
return 0
}
return 1
}),
updateProcThreadAttribute: vi.fn(() => 1),
deleteProcThreadAttributeList,
closeHandle: vi.fn(() => 1),
getLastError: vi.fn(() => 5),
formatMessageW: vi.fn(() => 0),
@ -37,7 +47,8 @@ describe('spawnInheritedJobProcess allocation cleanup', () => {
cwd: 'C:\\',
token: 70n as NativePtr,
})).toThrow('process-info allocation failed')
expect(free).toHaveBeenCalledOnce()
expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce()
expect(free).toHaveBeenCalledTimes(4)
})
it('frees process info after a successful inherited spawn', () => {
@ -46,6 +57,15 @@ describe('spawnInheritedJobProcess allocation cleanup', () => {
setInformationJobObject: vi.fn(() => 1),
getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)),
setHandleInformation: vi.fn(() => 1),
initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
if (list === null) {
koffi.encode(size, 'size_t', 64)
return 0
}
return 1
}),
updateProcThreadAttribute: vi.fn(() => 1),
deleteProcThreadAttributeList: vi.fn(),
createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
koffi.encode(info, PROCESS_INFORMATION, {
hProcess: 60n,
@ -55,7 +75,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => {
})
return 1
}),
assignProcessToJobObject: vi.fn(() => 1),
resumeThread: vi.fn(() => 1),
closeHandle: vi.fn(() => 1),
getLastError: vi.fn(() => 5),
@ -68,7 +87,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => {
cwd: 'C:\\',
token: 70n as NativePtr,
})).toEqual({ pid: 1234, process: 60n, job: 50n })
expect(free).toHaveBeenCalledTimes(2)
expect(free).toHaveBeenCalledTimes(5)
})
})

View file

@ -21,6 +21,23 @@ import { PROCESS_INFORMATION } from '../src/ffi.ts'
const PVOID = koffi.pointer('void')
function jobAttributeStubs(): Pick<
Win32ProcessBindings,
'initializeProcThreadAttributeList' | 'updateProcThreadAttribute' | 'deleteProcThreadAttributeList'
> {
return {
initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
if (list === null) {
koffi.encode(size, 'size_t', 64)
return 0
}
return 1
}),
updateProcThreadAttribute: vi.fn(() => 1),
deleteProcThreadAttributeList: vi.fn(),
}
}
/** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */
function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } {
const closed: bigint[] = []
@ -69,7 +86,7 @@ function resumeFailureApi(): {
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
return 1
}),
assignProcessToJobObject: vi.fn(() => 1),
...jobAttributeStubs(),
resumeThread,
getLastError: vi.fn(() => 5),
closeHandle,
@ -239,7 +256,7 @@ describe('spawnInheritedJobProcess failure paths', () => {
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
return 1
}),
assignProcessToJobObject: vi.fn(() => 1),
...jobAttributeStubs(),
resumeThread: vi.fn(() => 0),
getLastError: vi.fn(() => 5),
closeHandle,
@ -302,11 +319,34 @@ describe('spawnInheritedJobProcess failure paths', () => {
expect(closeHandle).toHaveBeenCalledWith(100n)
})
it('terminates the suspended child when Job assignment fails', () => {
const terminateProcess = vi.fn(() => 1)
it('closes the job when the attribute-list size query returns no size', () => {
const { api, closeHandle } = inheritedApi({
assignProcessToJobObject: vi.fn(() => 0),
terminateProcess,
initializeProcThreadAttributeList: vi.fn(() => 0),
})
expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }))
.toThrow(Win32Error)
expect(closeHandle).toHaveBeenCalledWith(100n)
})
it('closes the job when attribute-list initialization fails', () => {
const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
if (list === null) {
koffi.encode(size, 'size_t', 64)
return 0
}
return 0
})
const { api, closeHandle } = inheritedApi({ initializeProcThreadAttributeList })
expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }))
.toThrow(Win32Error)
expect(closeHandle).toHaveBeenCalledWith(100n)
})
it('deletes the attribute list and closes the job when atomic Job attachment fails', () => {
const deleteProcThreadAttributeList = vi.fn()
const { api, closeHandle } = inheritedApi({
updateProcThreadAttribute: vi.fn(() => 0),
deleteProcThreadAttributeList,
})
let caught: unknown
try {
@ -314,10 +354,8 @@ describe('spawnInheritedJobProcess failure paths', () => {
} catch (error) {
caught = error
}
expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 })
expect(terminateProcess).toHaveBeenCalledWith(200n, 1)
expect(closeHandle).toHaveBeenCalledWith(201n)
expect(closeHandle).toHaveBeenCalledWith(200n)
expect(caught).toMatchObject({ api: 'UpdateProcThreadAttribute', win32Code: 5 })
expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce()
expect(closeHandle).toHaveBeenCalledWith(100n)
})

View file

@ -7,7 +7,12 @@ import {
spawnInheritedJobProcess,
spawnPipedProcess,
} from '../src/index.ts'
import { CREATE_SUSPENDED } from '../src/abi.ts'
import {
CREATE_SUSPENDED,
EXTENDED_STARTUPINFO_PRESENT,
POINTER_SIZE,
PROC_THREAD_ATTRIBUTE_JOB_LIST,
} from '../src/abi.ts'
import { PROCESS_INFORMATION } from '../src/ffi.ts'
import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
@ -17,9 +22,12 @@ function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
api: Win32ProcessBindings
events: string[]
createProcessAsUserW: ReturnType<typeof vi.fn>
assignProcessToJobObject: ReturnType<typeof vi.fn>
initializeProcThreadAttributeList: ReturnType<typeof vi.fn>
updateProcThreadAttribute: ReturnType<typeof vi.fn>
attachedJob: () => NativePtr | null
} {
const events: string[] = []
let attachedJob: NativePtr | null = null
const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] =
overrides.createProcessAsUserW
?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
@ -33,7 +41,22 @@ function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
return 1
})
const createProcessAsUserW = vi.fn(createProcessAsUserWImpl)
const assignProcessToJobObject = vi.fn(() => { events.push('assign'); return 1 })
const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
if (list === null) {
events.push('attribute-size')
koffi.encode(size, 'size_t', 64)
return 0
}
events.push('attribute-init')
return 1
})
const updateProcThreadAttribute = vi.fn((_list, _flags, attribute: number, value: NativePtr) => {
if (attribute === PROC_THREAD_ATTRIBUTE_JOB_LIST) {
attachedJob = koffi.decode(value, PVOID) as NativePtr
events.push('attach-job')
}
return 1
})
const api = {
createJobObjectW: vi.fn(() => 50n),
setInformationJobObject: vi.fn(() => 1),
@ -42,7 +65,9 @@ function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
events.push(flags === 0 ? 'restore' : 'inherit')
return 1
}),
assignProcessToJobObject,
initializeProcThreadAttributeList,
updateProcThreadAttribute,
deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }),
resumeThread: vi.fn(() => { events.push('resume'); return 1 }),
terminateProcess: vi.fn(() => 1),
closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }),
@ -55,7 +80,9 @@ function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
api,
events,
createProcessAsUserW,
assignProcessToJobObject,
initializeProcThreadAttributeList,
updateProcThreadAttribute,
attachedJob: () => attachedJob,
}
}
@ -67,7 +94,9 @@ describe('spawnInheritedJobProcess', () => {
api,
events,
createProcessAsUserW,
assignProcessToJobObject,
initializeProcThreadAttributeList,
updateProcThreadAttribute,
attachedJob,
} = inheritedApi()
const child = spawnInheritedJobProcess(api, {
command: 'cmd.exe',
@ -76,9 +105,21 @@ describe('spawnInheritedJobProcess', () => {
token,
})
expect(child).toEqual({ pid: 1234, process: 60n, job: 50n })
expect(events.indexOf('assign')).toBeGreaterThan(events.indexOf('create'))
expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create'))
expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create'))
expect(events.indexOf('resume')).toBeGreaterThan(events.indexOf('create'))
expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n)
expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything())
expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything())
expect(updateProcThreadAttribute).toHaveBeenCalledWith(
expect.any(Buffer),
0,
PROC_THREAD_ATTRIBUTE_JOB_LIST,
expect.anything(),
POINTER_SIZE,
null,
null,
)
expect(attachedJob()).toBe(50n)
expect(createProcessAsUserW).toHaveBeenCalledWith(
token,
null,
@ -86,7 +127,7 @@ describe('spawnInheritedJobProcess', () => {
null,
null,
1,
CREATE_SUSPENDED,
CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT,
null,
'C:\\work',
expect.anything(),
@ -149,10 +190,10 @@ describe('spawnInheritedJobProcess', () => {
expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 })
})
it('terminates a restricted child when CreateProcessAsUserW returns a null thread handle', () => {
const terminateProcess = vi.fn(() => 1)
it('closes the atomic Job when CreateProcessAsUserW returns a null thread handle', () => {
const closeHandle = vi.fn(() => 1)
const { api } = inheritedApi({
terminateProcess,
closeHandle,
createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
koffi.encode(info, PROCESS_INFORMATION, {
hProcess: 60n,
@ -169,7 +210,8 @@ describe('spawnInheritedJobProcess', () => {
cwd: 'C:\\work',
token,
})).toThrow('null process/thread handles')
expect(terminateProcess).toHaveBeenCalledWith(60n, 1)
expect(closeHandle).toHaveBeenCalledWith(50n)
expect(closeHandle).toHaveBeenCalledWith(60n)
})
})

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildCommandLine, quoteArg } from '../src/process.ts'
import { quoteArg } from '../src/index.ts'
import { buildCommandLine } from '../src/process.ts'
const isWin32 = process.platform === 'win32'

View file

@ -13,11 +13,15 @@ int wmain()
P(offsetof(STARTUPINFOW, hStdInput));
P(offsetof(STARTUPINFOW, hStdOutput));
P(offsetof(STARTUPINFOW, hStdError));
P(sizeof(STARTUPINFOEXW));
P(offsetof(STARTUPINFOEXW, lpAttributeList));
P(sizeof(PROCESS_INFORMATION));
P(offsetof(PROCESS_INFORMATION, hProcess));
P(offsetof(PROCESS_INFORMATION, hThread));
P(offsetof(PROCESS_INFORMATION, dwProcessId));
P(CREATE_SUSPENDED);
P(EXTENDED_STARTUPINFO_PRESENT);
P(PROC_THREAD_ATTRIBUTE_JOB_LIST);
P(STARTF_USESTDHANDLES);
P(HANDLE_FLAG_INHERIT);
P(INFINITE);
@ -35,8 +39,12 @@ int wmain()
P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE);
static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size");
static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size");
static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset");
static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size");
static_assert(CREATE_SUSPENDED == 0x4, "create suspended");
static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag");
static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute");
static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag");
static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag");
static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size");

View file

@ -78,6 +78,10 @@ describe('CI workflow', () => {
const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record<string, unknown> & { run: string } => (
isRecord(step) && typeof step.run === 'string'
))
expect(nativeCommandSteps.some(step => (
step.run.includes('packages/subprocess/win32-process/verify/abi-probe.cpp')
&& step.run.includes('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp')
))).toBe(true)
expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete')
// wine-apt-cache: master-only, seeds the Wine apt cache.