Sfoglia il codice sorgente

fix(win32-process): close PR1 review gaps

pku-xht 3 settimane fa
parent
commit
4a722de4fa
26 ha cambiato i file con 435 aggiunte e 80 eliminazioni
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md
  4. 16 0
      .github/workflows/ci.yml
  5. 1 0
      packages/sandbox/sandbox-local/tests/packed-install.e2e.ts
  6. 1 0
      packages/sandbox/sandbox-windows-acl/src/index.ts
  7. 8 1
      packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts
  8. 11 2
      packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts
  9. 2 2
      packages/subprocess/README.i18n.yaml
  10. 1 1
      packages/subprocess/README.md
  11. 1 1
      packages/subprocess/README.zh.md
  12. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  13. 2 2
      packages/subprocess/win32-process/README.md
  14. 2 2
      packages/subprocess/win32-process/README.zh.md
  15. 8 0
      packages/subprocess/win32-process/src/abi.ts
  16. 23 2
      packages/subprocess/win32-process/src/ffi.ts
  17. 1 0
      packages/subprocess/win32-process/src/index.ts
  18. 124 0
      packages/subprocess/win32-process/src/job-attribute.ts
  19. 20 30
      packages/subprocess/win32-process/src/process.ts
  20. 65 0
      packages/subprocess/win32-process/tests/job-attribute.spec.ts
  21. 22 3
      packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts
  22. 48 10
      packages/subprocess/win32-process/tests/process-failure-paths.spec.ts
  23. 55 13
      packages/subprocess/win32-process/tests/process.spec.ts
  24. 2 1
      packages/subprocess/win32-process/tests/quote.spec.ts
  25. 8 0
      packages/subprocess/win32-process/verify/abi-probe.cpp
  26. 4 0
      scripts/ci-workflow.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml

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

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md

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

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md

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

+ 16 - 0
.github/workflows/ci.yml

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

+ 1 - 0
packages/sandbox/sandbox-local/tests/packed-install.e2e.ts

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

+ 1 - 0
packages/sandbox/sandbox-windows-acl/src/index.ts

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

+ 8 - 1
packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts

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

+ 11 - 2
packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts

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

+ 2 - 2
packages/subprocess/README.i18n.yaml

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

+ 1 - 1
packages/subprocess/README.md

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

+ 1 - 1
packages/subprocess/README.zh.md

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

+ 2 - 2
packages/subprocess/win32-process/README.i18n.yaml

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

+ 2 - 2
packages/subprocess/win32-process/README.md

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

+ 2 - 2
packages/subprocess/win32-process/README.zh.md

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

+ 8 - 0
packages/subprocess/win32-process/src/abi.ts

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

+ 23 - 2
packages/subprocess/win32-process/src/ffi.ts

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

+ 1 - 0
packages/subprocess/win32-process/src/index.ts

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

+ 124 - 0
packages/subprocess/win32-process/src/job-attribute.ts

@@ -0,0 +1,124 @@
+/** Package-private STARTUPINFOEXW ownership for atomic Job attachment. */
+
+import koffi from 'koffi'
+import * as abi from './abi.ts'
+import { STARTUPINFOW, throwWin32 } from './ffi.ts'
+import type { NativePtr, StartupInfoInput, Win32ProcessBindings } from './ffi.ts'
+
+type Ptr = ReturnType<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)
+  }
+}

+ 20 - 30
packages/subprocess/win32-process/src/process.ts

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

+ 65 - 0
packages/subprocess/win32-process/tests/job-attribute.spec.ts

@@ -0,0 +1,65 @@
+import koffi from 'koffi'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createJobStartupInfo } from '../src/job-attribute.ts'
+import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts'
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+function bindings(): {
+  api: Win32ProcessBindings
+  deleteProcThreadAttributeList: ReturnType<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()
+  })
+})

+ 22 - 3
packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts

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

+ 48 - 10
packages/subprocess/win32-process/tests/process-failure-paths.spec.ts

@@ -21,6 +21,23 @@ import { PROCESS_INFORMATION } from '../src/ffi.ts'
 
 const PVOID = koffi.pointer('void')
 
+function jobAttributeStubs(): Pick<
+  Win32ProcessBindings,
+  'initializeProcThreadAttributeList' | 'updateProcThreadAttribute' | 'deleteProcThreadAttributeList'
+> {
+  return {
+    initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => {
+      if (list === null) {
+        koffi.encode(size, 'size_t', 64)
+        return 0
+      }
+      return 1
+    }),
+    updateProcThreadAttribute: vi.fn(() => 1),
+    deleteProcThreadAttributeList: vi.fn(),
+  }
+}
+
 /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */
 function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType<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({
+      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({
-      assignProcessToJobObject: vi.fn(() => 0),
-      terminateProcess,
+      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)
   })
 

+ 55 - 13
packages/subprocess/win32-process/tests/process.spec.ts

@@ -7,7 +7,12 @@ import {
   spawnInheritedJobProcess,
   spawnPipedProcess,
 } from '../src/index.ts'
-import { CREATE_SUSPENDED } from '../src/abi.ts'
+import {
+  CREATE_SUSPENDED,
+  EXTENDED_STARTUPINFO_PRESENT,
+  POINTER_SIZE,
+  PROC_THREAD_ATTRIBUTE_JOB_LIST,
+} from '../src/abi.ts'
 import { PROCESS_INFORMATION } from '../src/ffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 
@@ -17,9 +22,12 @@ function inheritedApi(overrides: Partial<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)
   })
 })
 

+ 2 - 1
packages/subprocess/win32-process/tests/quote.spec.ts

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

+ 8 - 0
packages/subprocess/win32-process/verify/abi-probe.cpp

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

+ 4 - 0
scripts/ci-workflow.spec.ts

@@ -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.