فهرست منبع

fix(subprocess): preserve native runner stdio

pku-xht 1 ماه پیش
والد
کامیت
40279aea17
22فایلهای تغییر یافته به همراه404 افزوده شده و 296 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md
  3. 4 4
      .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md
  4. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  5. 3 2
      packages/subprocess/subprocess-local/README.md
  6. 3 2
      packages/subprocess/subprocess-local/README.zh.md
  7. 71 0
      packages/subprocess/subprocess-local/src/linux-execve.ts
  8. 5 4
      packages/subprocess/subprocess-local/src/linux-scope.ts
  9. 14 5
      packages/subprocess/subprocess-local/src/runner-launch.ts
  10. 15 8
      packages/subprocess/subprocess-local/src/spawn-runner.ts
  11. 8 6
      packages/subprocess/subprocess-local/src/windows-job.ts
  12. 104 0
      packages/subprocess/subprocess-local/tests/linux-execve.spec.ts
  13. 12 7
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  14. 29 24
      packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts
  15. 9 5
      packages/subprocess/subprocess-local/tests/windows-job.spec.ts
  16. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  17. 3 3
      packages/subprocess/win32-process/README.md
  18. 3 3
      packages/subprocess/win32-process/README.zh.md
  19. 16 7
      packages/subprocess/win32-process/src/ffi.ts
  20. 2 1
      packages/subprocess/win32-process/src/index.ts
  21. 62 99
      packages/subprocess/win32-process/src/process.ts
  22. 31 106
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: e323bb98658a1cfc8373283e0e95fbd7f752afad
-2026-08-28-subprocess-native-containment.zh.md: fc60c3d449974e7558013b4afd95d31d751f8689
+2026-08-28-subprocess-native-containment.md: c4fad5a11be450a382a2fc66dbae81e30a0e84c8
+2026-08-28-subprocess-native-containment.zh.md: c8185e0e90a69440c2441fc95258261bb091300b

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -18,9 +18,9 @@ An ordinary `SubprocessHandle` has no PID or public startup state. `.done` repor
 
 ### Linux scope and one-shot bootstrap
 
-Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, `process.execve()`, the readable user manager, and literal-argv transient-scope support. A positive result is not cached. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback.
+Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, the libc `execve` and `fcntl` bindings, the readable user manager, and literal-argv transient-scope support. A positive result is not cached. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback.
 
-The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, and calls `execve()` with the original argv. The bootstrap becomes the target in place; it does not remain as a supervisor.
+The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
 Request consumption or a manager observation of the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. After establishment, an inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
@@ -28,7 +28,7 @@ The ordinary target result still comes from the same child process. The PTY path
 
 ### Windows runner and Job
 
-The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. The runner resolves a separate `CreateProcessW` application path with Node's target-cwd/PATH search order while preserving the original argv entry. Target stdin, stdout, and stderr remain real Node-created standard handles: the runner temporarily makes its inherited handles inheritable, creates the target with `STARTF_USESTDHANDLES`, and then destroys its own Node/libuv standard streams so only target copies keep parent pipes open. User bytes never pass through IPC.
+The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. The shared Win32 layer resolves those CRT descriptors through UCRT `_get_osfhandle`, passes the resulting OS handles through `STARTF_USESTDHANDLES`, and preserves a separately resolved `CreateProcessW` application path without changing the original argv entry. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the carrier streams as the ordinary handle's stdio, and user bytes never pass through IPC.
 
 The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`.
 
@@ -54,7 +54,7 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
-- Provider and protocol suites pin synchronous NUL rejection before launch side effects, strict request/result decoding, target cwd and complete environment restoration, private-variable collision, Linux PATH lookup with preserved argv, pre-exec error ownership, the three scope-establishment states, all four Windows result branches, start cancellation, result-send and IPC-disconnect failures, stdio settlement, active-process quiescence, and unique handle cleanup.
+- Provider and protocol suites pin synchronous NUL rejection before launch side effects, strict request/result decoding, target cwd and complete environment restoration, private-variable collision, Linux PATH lookup with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, all four Windows result branches, start cancellation, result-send and IPC-disconnect failures, isolated carrier-descriptor closure, stdio settlement, active-process quiescence, and unique handle cleanup.
 - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
 - Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
 - Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains.

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -18,9 +18,9 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 ### Linux scope 与 one-shot bootstrap
 
-每次符合条件的 Linux 普通或 PTY spawn 都会重新检查准确 runner 入口、`process.execve()`、可读的 user manager 与保留 literal argv 的 transient-scope 支持。正向结果不缓存。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。
+每次符合条件的 Linux 普通或 PTY spawn 都会重新检查准确 runner 入口、libc `execve` 与 `fcntl` bindings、可读的 user manager 与保留 literal argv 的 transient-scope 支持。正向结果不缓存。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。
 
-parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件,并使用原始 argv 调用 `execve()`。bootstrap 会原地成为 target,不作为常驻 supervisor。
+parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
 request 被消费或 manager 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。建立之后,inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
@@ -28,7 +28,7 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。
 
 ### Windows runner 与 Job
 
-Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。runner 按 Node 的 target-cwd/PATH 搜索顺序解析独立的 `CreateProcessW` application path,同时保留原始 argv 项。target stdin、stdout 与 stderr 继续使用 Node 创建的真实标准句柄:runner 临时把继承的句柄设为可继承,通过 `STARTF_USESTDHANDLES` 原样传递,随后销毁自己持有的 Node/libuv 标准流,使 parent pipe 只由 target 副本保持打开。用户字节绝不经过 IPC。
+Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。共享 Win32 层通过 UCRT `_get_osfhandle` 解析这些 CRT 描述符,经 `STARTF_USESTDHANDLES` 传递对应 OS handle,并保留单独解析的 `CreateProcessW` application path,而不改变原始 argv 项。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。
 
 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。
 
@@ -54,7 +54,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
-- provider 与协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/result 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 的 Linux PATH 查找、pre-exec error ownership、三种 scope 建立状态、全部 4 个 Windows result 分支、startup cancellation、result-send 与 IPC-disconnect failure、stdio settlement、active-process 完全停稳,以及唯一 handle cleanup。
+- provider 与协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/result 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 的 Linux PATH 查找、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态、全部 4 个 Windows result 分支、startup cancellation、result-send 与 IPC-disconnect failure、隔离 carrier 描述符关闭、stdio settlement、active-process 完全停稳,以及唯一 handle cleanup。
 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
 - native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
 - 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。

+ 2 - 2
packages/subprocess/subprocess-local/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/subprocess-local/README.md
-README.md: eb38e2f45bad8e4b985177c656e5cb2762a86654
-README.zh.md: e7fb4d083bc52c27e39750486d98a41b404121dc
+README.md: 4fcc0812819f2960d9cacd3ef07b1e47da4126e1
+README.zh.md: c5b36637dca64587981aa6a349c9bf85cfb6acba

+ 3 - 2
packages/subprocess/subprocess-local/README.md

@@ -78,6 +78,7 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux
 | [`src/spawn.ts`](src/spawn.ts) | Shared process plumbing: direct outcomes, tail-keep collection, spill files, and fallback spawning |
 | [`src/managed-owner.ts`](src/managed-owner.ts) | Private signal-and-wait owner used by each ordinary handle |
 | [`src/linux-scope.ts`](src/linux-scope.ts) | Linux user-systemd capability checks, scope launch, signalling, and quiescence |
+| [`src/linux-execve.ts`](src/linux-execve.ts) | Linux libc image replacement and inherited-standard-descriptor preservation |
 | [`src/windows-job.ts`](src/windows-job.ts) | Windows Job capability checks and helper launch |
 | [`src/runner-launch.ts`](src/runner-launch.ts) | Source, built, and packaged private-runner selection |
 | [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux one-shot exec bootstrap and Windows Job runner |
@@ -89,7 +90,7 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux
 
 ### Main flow
 
-A spawn synchronously validates the final argv, cwd, and environment, selects containment before the user command can run, and returns a handle while target identity remains private. Linux ordinary and terminal launches use a private one-shot request whose scoped bootstrap restores the target cwd and environment before replacing itself with the target. Windows ordinary launches use one IPC channel for the start request, termination, and strict direct result; the runner creates the target suspended, assigns it to the Job, and only then resumes it. `done` settles the direct command after its stdio barrier, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty.
+A spawn synchronously validates the final argv, cwd, and environment, selects containment before the user command can run, and returns a handle while target identity remains private. Linux ordinary and terminal launches use a private one-shot request whose scoped bootstrap restores the target cwd and environment, resolves the executable, clears close-on-exec on fd 0 through fd 2, and enters libc `execve()` with the original argv. Windows ordinary launches isolate runner fd 0 through fd 2, reserve fd 3 for IPC, and carry target stdio on fd 4 through fd 6; the runner resolves those CRT descriptors to OS handles, creates the target suspended, assigns it to the Job, resumes it, and closes only the carrier descriptors. `done` settles the direct command after its stdio barrier, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty.
 
 ### Safety invariants
 
@@ -129,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
 These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
 
 - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists.
-- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory and polls unresolved scope state; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files.
+- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory and polls unresolved scope state; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files.
 - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind.
 - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest.
 - **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership.

+ 3 - 2
packages/subprocess/subprocess-local/README.zh.md

@@ -78,6 +78,7 @@ kind: "package-reference"
 | [`src/spawn.ts`](src/spawn.ts) | 共享进程管道:直接结果、保尾收集、spill 文件与 fallback spawn |
 | [`src/managed-owner.ts`](src/managed-owner.ts) | 每个普通句柄使用的私有信号与等待 owner |
 | [`src/linux-scope.ts`](src/linux-scope.ts) | Linux user-systemd 能力检查、scope 启动、信号发送与完全停稳 |
+| [`src/linux-execve.ts`](src/linux-execve.ts) | Linux libc 进程映像替换与继承标准文件描述符保留 |
 | [`src/windows-job.ts`](src/windows-job.ts) | Windows Job 能力检查与 helper 启动 |
 | [`src/runner-launch.ts`](src/runner-launch.ts) | source、built 与 packaged 私有 runner 选择 |
 | [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux 一次性 exec bootstrap 与 Windows Job runner |
@@ -89,7 +90,7 @@ kind: "package-reference"
 
 ### 主流程
 
-一次 spawn 会同步校验最终 argv、cwd 与环境,在用户命令可能运行前选择 containment,并在目标身份保持私有的情况下返回句柄。Linux 普通命令与终端启动使用私有的一次性请求;scope 内的 bootstrap 会恢复目标 cwd 与环境,再用目标程序替换自身。Windows 普通命令使用同一条 IPC 通道传递启动请求、终止命令与严格的直接结果;runner 以 suspended 状态创建目标,将其加入 Job 后才恢复运行。`done` 会在直接命令及其 stdio 屏障结算后完成,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察 session 变空。
+一次 spawn 会同步校验最终 argv、cwd 与环境,在用户命令可能运行前选择 containment,并在目标身份保持私有的情况下返回句柄。Linux 普通命令与终端启动使用私有的一次性请求;scope 内的 bootstrap 会恢复目标 cwd 与环境、解析可执行文件、清除 fd 0 至 fd 2 的 close-on-exec 标记,再以原始 argv 进入 libc `execve()`。Windows 普通命令会隔离 runner 的 fd 0 至 fd 2、把 fd 3 留给 IPC,并用 fd 4 至 fd 6 承载 target stdio;runner 把这些 CRT 描述符解析成 OS handle,以 suspended 状态创建 target,将其加入 Job、恢复运行,再只关闭 carrier 描述符。`done` 会在 direct command 及其 stdio 屏障结算后完成,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察 session 变空。
 
 ### 安全不变式
 
@@ -129,7 +130,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。
 
 - **native ownership 有明确宿主要求**——Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
-- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,并在 scope 状态尚未确定时轮询;Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。
+- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,并在 scope 状态尚未确定时轮询;Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。
 - **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。
 - **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默与计时档覆盖。
 - **fallback 终端 ownership 仍依赖观察**——在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有终端 session,就可能逃出进程表扫描。本地提供方不会新增持续进程表监视器;受支持的 Linux native 模式改由 scope membership 持有这些后代。

+ 71 - 0
packages/subprocess/subprocess-local/src/linux-execve.ts

@@ -0,0 +1,71 @@
+/** Lazy libc execve and descriptor bindings used by the one-shot Linux bootstrap. */
+
+import { getSystemErrorMessage, getSystemErrorName } from 'node:util'
+import koffi from 'koffi'
+
+/** Replace the current process image while preserving the supplied argv and environment. */
+export type LinuxExecve = (
+  file: string,
+  argv: string[],
+  env: Record<string, string>,
+) => never
+
+type NativeExecve = (
+  file: string,
+  argv: Array<string | null>,
+  envp: Array<string | null>,
+) => number
+
+type NativeFcntl = (fd: number, command: number, argument: number) => number
+
+const STANDARD_FILE_DESCRIPTORS = [0, 1, 2] as const
+const F_GETFD = 1
+const F_SETFD = 2
+const FD_CLOEXEC = 1
+
+let cachedExecve: LinuxExecve | undefined
+
+function systemError(errno: number, syscall: string, path?: string): Error {
+  const uvError = -errno
+  const code = getSystemErrorName(uvError)
+  const detail = getSystemErrorMessage(uvError)
+  const subject = path === undefined ? syscall : `${syscall} '${path}'`
+  const error = Object.assign(new Error(`${code}: ${detail}, ${subject}`), {
+    code,
+    errno,
+    syscall,
+  })
+  return path === undefined ? error : Object.assign(error, { path })
+}
+
+/**
+ * Load libc's execve and fcntl symbols on first use and retain the native bindings.
+ * @returns a process-replacing execve operation that throws Node-style errors on failure.
+ */
+export function loadLinuxExecve(): LinuxExecve {
+  if (cachedExecve !== undefined) return cachedExecve
+  const libc = koffi.load(null)
+  const nativeExecve = libc.func(
+    'int execve(const char *pathname, const char **argv, const char **envp)',
+  ) as NativeExecve
+  const nativeFcntl = libc.func(
+    'int fcntl(int fd, int cmd, int arg)',
+  ) as NativeFcntl
+  cachedExecve = (file, argv, env) => {
+    for (const fd of STANDARD_FILE_DESCRIPTORS) {
+      const flags = nativeFcntl(fd, F_GETFD, 0)
+      if (flags === -1) throw systemError(koffi.errno(), 'fcntl')
+      if ((flags & FD_CLOEXEC) === 0) continue
+      if (nativeFcntl(fd, F_SETFD, flags & ~FD_CLOEXEC) === -1) {
+        throw systemError(koffi.errno(), 'fcntl')
+      }
+    }
+    nativeExecve(
+      file,
+      [...argv, null],
+      [...Object.entries(env).map(([key, value]) => `${key}=${value}`), null],
+    )
+    throw systemError(koffi.errno(), 'execve', file)
+  }
+  return cachedExecve
+}

+ 5 - 4
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -9,6 +9,7 @@ import type {
   SubprocessSpawnSpec,
   SubprocessTerminalSpawnSpec,
 } from '@deepseek-ai/dsh-subprocess'
+import { loadLinuxExecve } from './linux-execve.ts'
 import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
 import {
   cleanupLinuxLaunchFiles,
@@ -36,7 +37,7 @@ export interface LinuxScopeInternals {
   runnerInvocation?: RunnerInvocation
   resolveRunnerInvocation?: () => RunnerInvocation
   runnerAvailable?: (invocation: RunnerInvocation) => boolean
-  execveAvailable?: boolean
+  loadLinuxExecve?: typeof loadLinuxExecve
 }
 
 interface SystemctlResult {
@@ -91,13 +92,13 @@ export function probeLinuxUserManager(internals: LinuxScopeInternals = {}): bool
 }
 
 /**
- * Confirm this exact runner entry and Node execve support without a probe mode.
- * @param internals - optional runner and execve seams used by tests.
+ * Confirm this exact runner entry and libc execve binding without a probe mode.
+ * @param internals - optional runner and libc-binding seams used by tests.
  * @returns whether the bootstrap can enter the final target.
  */
 export function probeLinuxBootstrap(internals: LinuxScopeInternals = {}): boolean {
-  if (!(internals.execveAvailable ?? typeof process.execve === 'function')) return false
   try {
+    ;(internals.loadLinuxExecve ?? loadLinuxExecve)()
     const invocation = internals.runnerInvocation
       ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)()
     return (internals.runnerAvailable ?? runnerInvocationAvailable)(invocation)

+ 14 - 5
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -90,22 +90,31 @@ export function parseRunnerTargetArgv(argv: readonly string[]): string[] {
 }
 
 /**
- * Build the stdio inherited unchanged by the target, optionally with Node IPC on fd 3.
+ * Build direct Linux target stdio, or isolated Windows runner stdio with IPC
+ * on fd 3 and target carriers on fd 4 through fd 6.
  * @param spec - ordinary subprocess request whose stdio modes are preserved.
- * @param ipc - whether to append the private Node IPC descriptor.
+ * @param ipc - whether to isolate the runner and add its private Node IPC descriptor.
  * @returns child-process stdio options for the runner.
  */
 export function runnerStdio(
   spec: SubprocessSpawnSpec,
   ipc: boolean,
 ): StdioOptions {
-  const stdio: StdioOptions = [
+  const targetStdio: StdioOptions = [
     spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
     spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe',
     spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe',
   ]
-  if (ipc) (stdio as Array<string>).push('ipc')
-  return stdio
+  if (!ipc) return targetStdio
+  return [
+    'ignore',
+    'ignore',
+    'ignore',
+    'ipc',
+    targetStdio[0],
+    spec.stdio.stdout === 'inherit' ? 1 : 'pipe',
+    spec.stdio.stderr === 'inherit' ? 2 : 'pipe',
+  ]
 }
 
 function windowsEnvironmentValue(

+ 15 - 8
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -1,8 +1,8 @@
 /** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */
 
+import { closeSync } from 'node:fs'
 import { posix } from 'node:path'
 import {
-  closeCurrentProcessStandardStreams,
   closeHandleChecked,
   isJobEmpty,
   loadWin32ProcessBindings,
@@ -11,7 +11,11 @@ import {
   terminateJob,
   Win32Error,
 } from '@deepseek-ai/dsh-win32-process'
-import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process'
+import type {
+  CurrentTokenProcessBindings,
+  NativePtr,
+} from '@deepseek-ai/dsh-win32-process'
+import { loadLinuxExecve } from './linux-execve.ts'
 import {
   consumeLinuxLaunchRequest,
   isWindowsTerminateRequest,
@@ -40,9 +44,9 @@ type RunnerHost = Pick<NodeJS.Process, 'env' | 'exitCode' | 'connected' | 'cwd'
 /** Injectable operations used by the protocol-owner tests. */
 export interface SpawnRunnerInternals {
   execve(file: string, argv: string[], env: Record<string, string>): never
-  loadWin32ProcessBindings(): Win32ProcessBindings
+  loadWin32ProcessBindings(): CurrentTokenProcessBindings
   spawnCurrentTokenJobProcess: typeof spawnCurrentTokenJobProcess
-  closeCurrentProcessStandardStreams: typeof closeCurrentProcessStandardStreams
+  closeFileDescriptor(fileDescriptor: number): void
   resolveWindowsExecutable: typeof resolveWindowsExecutable
   pollProcessExit: typeof pollProcessExit
   isJobEmpty: typeof isJobEmpty
@@ -52,10 +56,10 @@ export interface SpawnRunnerInternals {
 
 const defaultInternals: SpawnRunnerInternals = {
   /* v8 ignore next -- source/built/packaged subprocess smoke executes this only in a replaceable child process. */
-  execve: (file, argv, env) => (process.execve as NonNullable<typeof process.execve>)(file, argv, env),
+  execve: (file, argv, env) => loadLinuxExecve()(file, argv, env),
   loadWin32ProcessBindings,
   spawnCurrentTokenJobProcess,
-  closeCurrentProcessStandardStreams,
+  closeFileDescriptor: closeSync,
   resolveWindowsExecutable,
   pollProcessExit,
   isJobEmpty,
@@ -187,7 +191,7 @@ function sendMessage(host: RunnerHost, result: WindowsRunnerResult): Promise<voi
 }
 
 class WindowsJobRunner {
-  private api: Win32ProcessBindings | undefined
+  private api: CurrentTokenProcessBindings | undefined
   private processHandle: NativePtr | undefined
   private jobHandle: NativePtr | undefined
   private pollTimer: ReturnType<typeof setInterval> | undefined
@@ -270,11 +274,14 @@ class WindowsJobRunner {
         applicationName,
         args,
         cwd: request.cwd,
+        stdio: { stdin: 4, stdout: 5, stderr: 6 },
       })
       this.processHandle = spawned.process
       this.jobHandle = spawned.job
       this.committed = true
-      this.internals.closeCurrentProcessStandardStreams(this.api)
+      for (const fileDescriptor of [4, 5, 6]) {
+        this.internals.closeFileDescriptor(fileDescriptor)
+      }
       if (this.startCancellationPending()) this.terminateOwnedJob()
       this.pollTimer = setInterval(() => { this.poll() }, 10)
     } catch (error) {

+ 8 - 6
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -1,6 +1,7 @@
 /** Windows parent-side launch and ownership for the private Job runner. */
 
 import { spawn } from 'node:child_process'
+import type { Readable, Writable } from 'node:stream'
 import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import {
   loadWin32ProcessBindings,
@@ -31,8 +32,9 @@ export interface WindowsJobInternals {
   probeCurrentTokenJobSupport?: typeof probeCurrentTokenJobSupport
 }
 
-type RunnerProcess = Omit<ReturnType<typeof spawn>, 'send'> & {
+type RunnerProcess = Omit<ReturnType<typeof spawn>, 'send' | 'stdio'> & {
   send?: ReturnType<typeof spawn>['send']
+  stdio: Array<Readable | Writable | null>
 }
 
 /**
@@ -108,7 +110,7 @@ export function launchWindowsJob(
 ): ManagedProcessLaunch {
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [command, ...prefix] = invocation
-  const child: RunnerProcess = (internals.spawn ?? spawn)(command, [
+  const child = (internals.spawn ?? spawn)(command, [
     ...prefix,
     '--',
     ...spec.argv,
@@ -116,7 +118,7 @@ export function launchWindowsJob(
     cwd: process.cwd(),
     env: runnerEnvironment(WINDOWS_RUNNER_SELECTION),
     stdio: runnerStdio(spec, true),
-  })
+  }) as RunnerProcess
 
   const direct = Promise.withResolvers<SubprocessOutcome>()
   const infrastructure = Promise.withResolvers<never>()
@@ -199,9 +201,9 @@ export function launchWindowsJob(
   }
 
   return {
-    stdin: child.stdin,
-    stdout: child.stdout,
-    stderr: child.stderr,
+    stdin: child.stdio[4] as Writable | null,
+    stdout: child.stdio[5] as Readable | null,
+    stderr: child.stdio[6] as Readable | null,
     direct: direct.promise,
     owner,
     infrastructureFailure: infrastructure.promise,

+ 104 - 0
packages/subprocess/subprocess-local/tests/linux-execve.spec.ts

@@ -0,0 +1,104 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+afterEach(() => {
+  vi.doUnmock('koffi')
+  vi.resetModules()
+})
+
+describe('Linux libc execve binding', () => {
+  it('preserves inherited stdio, null-terminates argv and envp, and reports execve errno', async () => {
+    const nativeExecve = vi.fn(() => -1)
+    const nativeFcntl = vi.fn((fd: number, command: number) => {
+      if (command === 2) return 0
+      return [1, 0, 5][fd]
+    })
+    const func = vi.fn((declaration: string) => declaration.includes('execve')
+      ? nativeExecve
+      : nativeFcntl)
+    const load = vi.fn(() => ({ func }))
+    const errno = vi.fn(() => 2)
+    vi.doMock('koffi', () => ({ default: { errno, load } }))
+
+    const { loadLinuxExecve } = await import('../src/linux-execve.ts')
+    const execve = loadLinuxExecve()
+    expect(loadLinuxExecve()).toBe(execve)
+    expect(load).toHaveBeenCalledExactlyOnceWith(null)
+    expect(func.mock.calls).toEqual([
+      ['int execve(const char *pathname, const char **argv, const char **envp)'],
+      ['int fcntl(int fd, int cmd, int arg)'],
+    ])
+
+    let failure: unknown
+    try {
+      execve('/missing/tool', ['tool', 'literal arg'], { A: '1', EMPTY: '' })
+    } catch (error) {
+      failure = error
+    }
+    expect(nativeFcntl.mock.calls).toEqual([
+      [0, 1, 0],
+      [0, 2, 0],
+      [1, 1, 0],
+      [2, 1, 0],
+      [2, 2, 4],
+    ])
+    expect(nativeExecve).toHaveBeenCalledExactlyOnceWith(
+      '/missing/tool',
+      ['tool', 'literal arg', null],
+      ['A=1', 'EMPTY=', null],
+    )
+    expect(errno).toHaveBeenCalledOnce()
+    expect(failure).toMatchObject({
+      code: 'ENOENT',
+      errno: 2,
+      syscall: 'execve',
+      path: '/missing/tool',
+    })
+    expect(failure).toBeInstanceOf(Error)
+    expect((failure as Error).message).toContain("ENOENT: no such file or directory, execve '/missing/tool'")
+  })
+
+  it('reports failure to read descriptor flags before replacing the process', async () => {
+    const nativeExecve = vi.fn()
+    const nativeFcntl = vi.fn(() => -1)
+    const func = vi.fn((declaration: string) => declaration.includes('execve')
+      ? nativeExecve
+      : nativeFcntl)
+    const errno = vi.fn(() => 9)
+    vi.doMock('koffi', () => ({ default: { errno, load: () => ({ func }) } }))
+
+    const { loadLinuxExecve } = await import('../src/linux-execve.ts')
+    expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({
+      code: 'EBADF',
+      errno: 9,
+      syscall: 'fcntl',
+    }))
+    expect(nativeFcntl).toHaveBeenCalledExactlyOnceWith(0, 1, 0)
+    expect(nativeExecve).not.toHaveBeenCalled()
+    expect(errno).toHaveBeenCalledOnce()
+  })
+
+  it('reports failure to clear close-on-exec before replacing the process', async () => {
+    const nativeExecve = vi.fn()
+    const nativeFcntl = vi.fn()
+      .mockReturnValueOnce(1)
+      .mockReturnValueOnce(-1)
+    const func = vi.fn((declaration: string) => declaration.includes('execve')
+      ? nativeExecve
+      : nativeFcntl)
+    const errno = vi.fn(() => 5)
+    vi.doMock('koffi', () => ({ default: { errno, load: () => ({ func }) } }))
+
+    const { loadLinuxExecve } = await import('../src/linux-execve.ts')
+    expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({
+      code: 'EIO',
+      errno: 5,
+      syscall: 'fcntl',
+    }))
+    expect(nativeFcntl.mock.calls).toEqual([
+      [0, 1, 0],
+      [0, 2, 0],
+    ])
+    expect(nativeExecve).not.toHaveBeenCalled()
+    expect(errno).toHaveBeenCalledOnce()
+  })
+})

+ 12 - 7
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -105,7 +105,7 @@ function launch(
     systemctl: overrides.systemctl ?? '/bin/systemctl',
     runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'],
     ...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable },
-    ...overrides.execveAvailable === undefined ? {} : { execveAvailable: overrides.execveAvailable },
+    ...overrides.loadLinuxExecve === undefined ? {} : { loadLinuxExecve: overrides.loadLinuxExecve },
   })
   const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV]
   if (requestPath === undefined) throw new Error('launch did not publish a request locator')
@@ -117,19 +117,24 @@ describe('Linux native capability selection', () => {
   it('rechecks bootstrap, user manager, and literal transient-scope support', () => {
     const spawnSync = vi.fn(() => ({ status: 0, error: undefined }))
     const runnerAvailable = vi.fn(() => true)
+    const loadLinuxExecve = vi.fn(() => vi.fn() as never)
     const inputs = {
       spawnSync: spawnSync as never,
       runnerAvailable,
       runnerInvocation: ['/usr/bin/node', '/runner.js'] as [string, ...string[]],
-      execveAvailable: true,
+      loadLinuxExecve,
       systemdRun: '/bin/systemd-run',
       systemctl: '/bin/systemctl',
     }
     expect(probeLinuxNative(inputs)).toBe(true)
     expect(probeLinuxNative(inputs)).toBe(true)
     expect(runnerAvailable).toHaveBeenCalledTimes(2)
+    expect(loadLinuxExecve).toHaveBeenCalledTimes(2)
     expect(spawnSync).toHaveBeenCalledTimes(4)
-    expect(probeLinuxBootstrap({ ...inputs, execveAvailable: false })).toBe(false)
+    expect(probeLinuxBootstrap({
+      ...inputs,
+      loadLinuxExecve: () => { throw new Error('libc execve missing') },
+    })).toBe(false)
   })
 
   it('reports each failed dynamic prerequisite without executing a target', () => {
@@ -140,12 +145,12 @@ describe('Linux native capability selection', () => {
       spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never,
     })).toBe(false)
     expect(probeLinuxBootstrap({
-      execveAvailable: true,
+      loadLinuxExecve: () => vi.fn() as never,
       runnerInvocation: ['/missing'],
       runnerAvailable: () => false,
     })).toBe(false)
     expect(probeLinuxBootstrap({
-      execveAvailable: true,
+      loadLinuxExecve: () => vi.fn() as never,
       resolveRunnerInvocation: () => { throw new Error('runner resolution failed') },
     })).toBe(false)
   })
@@ -155,11 +160,11 @@ describe('Linux native capability selection', () => {
     expect(probeLinuxUserManager()).toBe(true)
     expect(probeLinuxScope()).toBe(true)
     expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2)
-    expect(probeLinuxBootstrap({ execveAvailable: true })).toBe(true)
+    expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true)
     expect(probeLinuxBootstrap({
       runnerInvocation: [process.execPath],
       runnerAvailable: () => true,
-    })).toBe(typeof process.execve === 'function')
+    })).toBe(process.platform !== 'win32')
   })
 })
 

+ 29 - 24
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -14,7 +14,10 @@ import { tmpdir } from 'node:os'
 import { join, posix } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Win32Error } from '@deepseek-ai/dsh-win32-process'
-import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process'
+import type {
+  CurrentTokenProcessBindings,
+  NativePtr,
+} from '@deepseek-ai/dsh-win32-process'
 import {
   cleanupLinuxLaunchFiles,
   consumeLinuxLaunchRequest,
@@ -90,13 +93,13 @@ function hostArgument(host: FakeRunnerHost): Parameters<typeof runSpawnRunner>[2
 function internals(overrides: Partial<SpawnRunnerInternals> = {}): SpawnRunnerInternals {
   return {
     execve: vi.fn(() => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) }),
-    loadWin32ProcessBindings: vi.fn(() => ({} as Win32ProcessBindings)),
+    loadWin32ProcessBindings: vi.fn(() => ({} as CurrentTokenProcessBindings)),
     spawnCurrentTokenJobProcess: vi.fn(() => ({
       pid: 123,
       process: 10n as NativePtr,
       job: 20n as NativePtr,
     })),
-    closeCurrentProcessStandardStreams: vi.fn(),
+    closeFileDescriptor: vi.fn(),
     resolveWindowsExecutable: vi.fn(() => 'C:\\resolved\\tool.exe'),
     pollProcessExit: vi.fn(() => 0),
     isJobEmpty: vi.fn(() => true),
@@ -249,11 +252,17 @@ describe('runner launch inputs', () => {
     expect(parseRunnerTargetArgv(['--', 'node', 'a'])).toEqual(['node', 'a'])
     expect(() => parseRunnerTargetArgv(['node'])).toThrow('private -- delimiter')
     expect(runnerStdio(spec, false)).toEqual(['pipe', 'pipe', 'inherit'])
-    expect(runnerStdio(spec, true)).toEqual(['pipe', 'pipe', 'inherit', 'ipc'])
+    expect(runnerStdio(spec, true)).toEqual([
+      'ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2,
+    ])
     expect(runnerStdio({
       ...spec,
       stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' },
     }, false)).toEqual(['ignore', 'inherit', 'pipe'])
+    expect(runnerStdio({
+      ...spec,
+      stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' },
+    }, true)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 'ignore', 1, 'pipe'])
   })
 
   it('validates every Node-baseline NUL location before launch', () => {
@@ -527,7 +536,8 @@ describe('Windows Job runner protocol owner', () => {
 
   it('sends target-exit only after suspended Job launch and closes runner stdio', async () => {
     const host = new FakeRunnerHost()
-    const native = internals()
+    const closeFileDescriptor = vi.fn()
+    const native = internals({ closeFileDescriptor })
     await runWindows(host, native)
     expect(native.resolveWindowsExecutable).toHaveBeenCalledWith(
       'tool.exe',
@@ -538,9 +548,12 @@ describe('Windows Job runner protocol owner', () => {
     )
     expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), {
       command: 'tool.exe', applicationName: 'C:\\resolved\\tool.exe', args: ['literal arg'], cwd: 'C:\\target',
+      stdio: { stdin: 4, stdout: 5, stderr: 6 },
     })
-    expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledTimes(1)
-    expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledWith(expect.anything())
+    expect(closeFileDescriptor).toHaveBeenCalledTimes(3)
+    expect(closeFileDescriptor).toHaveBeenNthCalledWith(1, 4)
+    expect(closeFileDescriptor).toHaveBeenNthCalledWith(2, 5)
+    expect(closeFileDescriptor).toHaveBeenNthCalledWith(3, 6)
     expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process')
     expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job')
     expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }])
@@ -548,32 +561,24 @@ describe('Windows Job runner protocol owner', () => {
     expect(host.env).toEqual({ TARGET: 'yes', dsh_subprocess_runner: 'restored' })
   })
 
-  it('lets asynchronous runner stdio close before the first Windows poll', async () => {
-    let tick: (() => void) | undefined
+  it('closes every target carrier before the first Windows poll', async () => {
+    const events: string[] = []
     const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => {
-      tick = callback
+      events.push('interval')
+      queueMicrotask(callback)
       return 1 as unknown as ReturnType<typeof setInterval>
     })
     try {
-      const events: string[] = []
-      let closeComplete = false
       const host = new FakeRunnerHost()
       const native = internals({
-        closeCurrentProcessStandardStreams: vi.fn(() => {
-          events.push('close-start')
-          queueMicrotask(() => {
-            closeComplete = true
-            events.push('close-complete')
-            tick?.()
-          })
-        }),
+        closeFileDescriptor: vi.fn((fileDescriptor) => { events.push(`close:${String(fileDescriptor)}`) }),
         pollProcessExit: vi.fn(() => {
-          events.push(`poll:${String(closeComplete)}`)
+          events.push('poll')
           return 0
         }),
       })
       await runWindows(host, native)
-      expect(events).toEqual(['close-start', 'close-complete', 'poll:true'])
+      expect(events).toEqual(['close:4', 'close:5', 'close:6', 'interval', 'poll'])
       expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }])
       expect(host.exitCode).toBe(0)
     } finally {
@@ -671,8 +676,8 @@ describe('Windows Job runner protocol owner', () => {
   it('handles commit-time termination reentrancy and termination failure', async () => {
     const reentrantHost = new FakeRunnerHost()
     const reentrant = internals({
-      closeCurrentProcessStandardStreams: vi.fn(() => {
-        reentrantHost.emit('message', { type: 'terminate' })
+      closeFileDescriptor: vi.fn((fileDescriptor) => {
+        if (fileDescriptor === 4) reentrantHost.emit('message', { type: 'terminate' })
       }),
       pollProcessExit: vi.fn(() => undefined),
       isJobEmpty: vi.fn(() => false),

+ 9 - 5
packages/subprocess/subprocess-local/tests/windows-job.spec.ts

@@ -13,6 +13,10 @@ class FakeChild extends EventEmitter {
   stdin = new PassThrough()
   stdout = new PassThrough()
   stderr = new PassThrough()
+  targetStdin = new PassThrough()
+  targetStdout = new PassThrough()
+  targetStderr = new PassThrough()
+  stdio = [null, null, null, null, this.targetStdin, this.targetStdout, this.targetStderr]
   sent: unknown[] = []
   killed: NodeJS.Signals[] = []
   sendError: Error | undefined
@@ -116,18 +120,18 @@ describe('Windows Job capability', () => {
 })
 
 describe('Windows parent runner contract', () => {
-  it('launches with real stdio plus IPC and sends cwd/env through the strict start message', () => {
+  it('isolates runner stdio, carries target stdio on fd 4 through fd 6, and sends cwd/env', () => {
     const { child, result, spawn } = launch()
     expect(spawn).toHaveBeenCalledWith('C:\\node.exe', [
       'C:\\runner.js', '--', 'tool.exe', 'literal arg',
     ], expect.objectContaining({
       cwd: process.cwd(),
-      stdio: ['pipe', 'pipe', 'inherit', 'ipc'],
+      stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2],
     }))
     expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' } }])
-    expect(result.stdin).toBe(child.stdin)
-    expect(result.stdout).toBe(child.stdout)
-    expect(result.stderr).toBe(child.stderr)
+    expect(result.stdin).toBe(child.targetStdin)
+    expect(result.stdout).toBe(child.targetStdout)
+    expect(result.stderr).toBe(child.targetStderr)
   })
 
   it('maps target-exit to direct outcome and clean close to range quiescence', async () => {

+ 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: a90b33e52fc97be4f70f718c8847e604137a683f
-README.zh.md: 85b6a00313e6ec1a0afb731e8eff59b32f2d0431
+README.md: 1affe4e0ba0622be02edc6f06f1c6dd88b24a60c
+README.zh.md: 6d908142cc6e721c40225773c14dcdd07e196e50

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

@@ -24,11 +24,11 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and
 <a id="behavior"></a>
 ## Behavior
 
-- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both 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 both process paths. `ffi.ts` lazily loads `kernel32.dll`, `advapi32.dll`, and `ucrtbase.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries.
 - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup.
 - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime.
 - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle.
-- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` temporarily marks the runner's standard handles inheritable, passes those exact handles through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the same runner; `closeCurrentProcessStandardStreams()` then closes each unique raw standard handle and destroys the corresponding Node/libuv stream owner so target exit can produce EOF at the parent.
+- **Ordinary Job runner primitive** — `CurrentTokenProcessSpawnOptions.stdio` names three runner CRT descriptors dedicated to target stdin, stdout, and stderr. `spawnCurrentTokenJobProcess()` resolves their OS handles through UCRT `_get_osfhandle`, temporarily marks those handles inheritable, passes them through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the runner, which can close its carrier descriptors without touching Node's own standard streams.
 - **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner.
 - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles.
 
@@ -52,7 +52,7 @@ The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their
 
 #### What the model sees
 
-Nothing directly. The package exposes `Win32ProcessBindings` and process primitives to the sandbox and ordinary runner, which own all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema.
+Nothing directly. The package exposes `Win32ProcessBindings`, `CurrentTokenProcessBindings`, and process primitives to the sandbox and ordinary runner, which own all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema.
 
 #### Token effect
 

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

@@ -24,11 +24,11 @@ kind: "package-library"
 <a id="behavior"></a>
 ## Behavior
 
-- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。
+- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll`、`advapi32.dll` 与 `ucrtbase.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。
 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。
 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。
 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。
-- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 临时把 runner 的标准句柄设为可继承,通过 `STARTF_USESTDHANDLES` 传入这些准确句柄,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给同一个 runner;`closeCurrentProcessStandardStreams()` 随后关闭每个唯一的原始标准句柄,并销毁对应的 Node/libuv stream owner,使 target 退出可以在 parent 产生 EOF。
+- **ordinary Job runner 原语** — `CurrentTokenProcessSpawnOptions.stdio` 指定三个专用于 target stdin、stdout 与 stderr 的 runner CRT 描述符。`spawnCurrentTokenJobProcess()` 通过 UCRT `_get_osfhandle` 解析对应 OS handle,临时把这些 handle 设为可继承,通过 `STARTF_USESTDHANDLES` 传入它们,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给 runner,后者可以关闭自己的 carrier 描述符,而不触碰 Node 自身的标准流。
 - **ordinary 停稳操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与 handle 关闭使 runner 保持唯一 native owner。
 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
 
@@ -52,7 +52,7 @@ Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载
 
 #### 模型看到什么
 
-没有直接内容。本包向 sandbox 与 ordinary runner 提供 `Win32ProcessBindings` 与进程原语;两者拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。
+没有直接内容。本包向 sandbox 与 ordinary runner 提供 `Win32ProcessBindings`、`CurrentTokenProcessBindings` 与进程原语;两者拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。
 
 #### Token 影响
 

+ 16 - 7
packages/subprocess/win32-process/src/ffi.ts

@@ -18,6 +18,8 @@ export interface Win32BindingContext {
   readonly kernel32: ReturnType<typeof koffi.load>
   /** Token and security APIs. */
   readonly advapi32: ReturnType<typeof koffi.load>
+  /** Universal CRT file-descriptor operations. */
+  readonly ucrtbase: ReturnType<typeof koffi.load>
   /** Bind one stdcall function from a loaded Win32 library. */
   readonly bind: (
     library: ReturnType<typeof koffi.load>,
@@ -120,6 +122,11 @@ export interface Win32ProcessBindings {
   getStdHandle(stdHandle: number): NativePtr
 }
 
+/** Generic Win32 calls plus current-process CRT descriptor lookup. */
+export interface CurrentTokenProcessBindings extends Win32ProcessBindings {
+  getOsfHandle(fileDescriptor: number): number | bigint
+}
+
 /** Koffi STARTUPINFOW layout. */
 export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', {
   cb: 'uint32',
@@ -229,26 +236,27 @@ export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput {
 }
 
 let cachedContext: Win32BindingContext | undefined
-let cached: Win32ProcessBindings | undefined
+let cached: CurrentTokenProcessBindings | undefined
 
 /* v8 ignore start -- exercised by native Windows ABI and sandbox jobs. */
 function bindingContext(): Win32BindingContext {
   if (cachedContext !== undefined) return cachedContext
   const kernel32 = koffi.load('kernel32.dll')
   const advapi32 = koffi.load('advapi32.dll')
+  const ucrtbase = koffi.load('ucrtbase.dll')
   const bind = (
     lib: ReturnType<typeof koffi.load>,
     name: string,
     result: Ptr | string,
     args: Array<Ptr | string>,
   ): unknown => lib.func('__stdcall', name, result, args)
-  cachedContext = { kernel32, advapi32, bind }
+  cachedContext = { kernel32, advapi32, ucrtbase, bind }
   return cachedContext
 }
 
-function bindings(): Win32ProcessBindings {
+function bindings(): CurrentTokenProcessBindings {
   if (cached !== undefined) return cached
-  const { kernel32, advapi32, bind } = bindingContext()
+  const { kernel32, advapi32, ucrtbase, bind } = bindingContext()
   cached = {
     closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]),
     getLastError: bind(kernel32, 'GetLastError', 'uint32', []),
@@ -281,7 +289,8 @@ function bindings(): Win32ProcessBindings {
     terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']),
     terminateJobObject: bind(kernel32, 'TerminateJobObject', 'int', [PVOID, 'uint32']),
     getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']),
-  } as unknown as Win32ProcessBindings
+    getOsfHandle: ucrtbase.func('_get_osfhandle', 'intptr_t', ['int']),
+  } as unknown as CurrentTokenProcessBindings
   return cached
 }
 
@@ -292,7 +301,7 @@ function bindings(): Win32ProcessBindings {
  */
 export function extendWin32ProcessBindings<Extension extends object>(
   create: (context: Win32BindingContext) => Extension,
-): Win32ProcessBindings & Extension {
+): CurrentTokenProcessBindings & Extension {
   return { ...bindings(), ...create(bindingContext()) }
 }
 
@@ -300,7 +309,7 @@ export function extendWin32ProcessBindings<Extension extends object>(
  * Load the generic process binding table without policy-specific extensions.
  * @returns shared Win32 process, stdio, and Job operations.
  */
-export function loadWin32ProcessBindings(): Win32ProcessBindings {
+export function loadWin32ProcessBindings(): CurrentTokenProcessBindings {
   return bindings()
 }
 /* v8 ignore stop */

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

@@ -14,12 +14,12 @@ export {
   throwWin32,
 } from './ffi.ts'
 export type {
+  CurrentTokenProcessBindings,
   NativePtr,
   Win32ProcessBindings,
 } from './ffi.ts'
 export {
   closeHandleChecked,
-  closeCurrentProcessStandardStreams,
   drainPipe,
   isJobEmpty,
   pollProcessExit,
@@ -31,6 +31,7 @@ export {
   waitForProcessExit,
 } from './process.ts'
 export type {
+  CurrentTokenStdioFileDescriptors,
   CurrentTokenProcessSpawnOptions,
   SpawnedJobProcess,
   SpawnedPipedProcess,

+ 62 - 99
packages/subprocess/win32-process/src/process.ts

@@ -15,7 +15,7 @@ import {
   throwLastError,
   throwWin32,
 } from './ffi.ts'
-import type { NativePtr, Win32ProcessBindings } from './ffi.ts'
+import type { CurrentTokenProcessBindings, NativePtr, Win32ProcessBindings } from './ffi.ts'
 
 /**
  * Quote one argument according to CommandLineToArgvW parsing.
@@ -66,6 +66,15 @@ interface ProcessSpawnOptions {
 export interface CurrentTokenProcessSpawnOptions extends ProcessSpawnOptions {
   /** Resolved executable path passed separately from the preserved argv entry. */
   applicationName?: string
+  /** Runner CRT descriptors carrying target stdin, stdout, and stderr. */
+  stdio: CurrentTokenStdioFileDescriptors
+}
+
+/** Runner CRT descriptors whose OS handles become the target standard handles. */
+export interface CurrentTokenStdioFileDescriptors {
+  stdin: number
+  stdout: number
+  stderr: number
 }
 
 /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */
@@ -325,34 +334,61 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr {
   return job
 }
 
+interface ProcessStandardHandles {
+  stdin: NativePtr
+  stdout: NativePtr
+  stderr: NativePtr
+}
+
+function inheritedStandardHandles(api: Win32ProcessBindings): ProcessStandardHandles {
+  const get = (selector: number, label: string): NativePtr => {
+    const handle = api.getStdHandle(selector)
+    if (!isNullPtr(handle)) return handle
+    throwLastError(api, 'GetStdHandle', `null ${label} handle`)
+  }
+  return {
+    stdin: get(abi.STD_INPUT_HANDLE, 'stdin'),
+    stdout: get(abi.STD_OUTPUT_HANDLE, 'stdout'),
+    stderr: get(abi.STD_ERROR_HANDLE, 'stderr'),
+  }
+}
+
+function targetCarrierHandles(
+  api: CurrentTokenProcessBindings,
+  descriptors: CurrentTokenStdioFileDescriptors,
+): ProcessStandardHandles {
+  const get = (fileDescriptor: number, label: string): NativePtr => {
+    const handle = api.getOsfHandle(fileDescriptor)
+    if (handle !== -1 && handle !== -1n) return BigInt(handle) as NativePtr
+    throw new Error(`_get_osfhandle failed for target ${label} fd ${String(fileDescriptor)}`)
+  }
+  return {
+    stdin: get(descriptors.stdin, 'stdin'),
+    stdout: get(descriptors.stdout, 'stdout'),
+    stderr: get(descriptors.stderr, 'stderr'),
+  }
+}
+
 /** Shared suspended-create, Job-assignment, and resume lifecycle. */
 function spawnJobProcess(
   api: Win32ProcessBindings,
-  options: CurrentTokenProcessSpawnOptions,
+  options: ProcessSpawnOptions,
+  resolveStdio: () => ProcessStandardHandles,
   createName: 'CreateProcessAsUserW' | 'CreateProcessW',
   create: (startupInfo: NativePtr, processInfo: NativePtr) => number,
 ): SpawnedJobProcess {
   const job = createKillOnCloseJob(api)
-  const getStdHandle = (selector: number, label: string): NativePtr => {
-    const handle = api.getStdHandle(selector)
-    if (!isNullPtr(handle)) return handle
-    const win32Code = api.getLastError()
-    api.closeHandle(job)
-    throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`)
-  }
-  const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin')
-  const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout')
-  const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr')
   const enabled: NativePtr[] = []
   let startupInfo: NativePtr | undefined
   let processInfo: NativePtr | undefined
   let created = 0
   let createFailureCode = 0
   try {
+    const stdio = resolveStdio()
     for (const [handle, label] of [
-      [stdIn, 'stdin'],
-      [stdOut, 'stdout'],
-      [stdErr, 'stderr'],
+      [stdio.stdin, 'stdin'],
+      [stdio.stdout, 'stdout'],
+      [stdio.stderr, 'stderr'],
     ] as const) {
       if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) {
         throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`)
@@ -363,9 +399,9 @@ function spawnJobProcess(
     encodeStartupInfo(startupInfo, {
       cb: abi.STARTUPINFOW_SIZE,
       dwFlags: abi.STARTF_USESTDHANDLES,
-      hStdInput: stdIn,
-      hStdOutput: stdOut,
-      hStdError: stdErr,
+      hStdInput: stdio.stdin,
+      hStdOutput: stdio.stdout,
+      hStdError: stdio.stderr,
     })
     processInfo = allocProcessInfo()
     created = create(startupInfo, processInfo)
@@ -438,7 +474,7 @@ export function spawnInheritedJobProcess(
   options: RestrictedProcessSpawnOptions,
 ): SpawnedJobProcess {
   const commandLine = buildCommandLine(options.command, options.args)
-  return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) =>
+  return spawnJobProcess(api, options, () => inheritedStandardHandles(api), 'CreateProcessAsUserW', (startupInfo, processInfo) =>
     createRestrictedProcess(
       api,
       options,
@@ -452,15 +488,15 @@ export function spawnInheritedJobProcess(
 /**
  * Spawn an ordinary process suspended, assign its Job, then resume it.
  * @param api - active binding table.
- * @param options - command, cwd, and argv.
+ * @param options - command, cwd, argv, and target carrier descriptors.
  * @returns caller-owned process and Job handles after successful resume.
  */
 export function spawnCurrentTokenJobProcess(
-  api: Win32ProcessBindings,
+  api: CurrentTokenProcessBindings,
   options: CurrentTokenProcessSpawnOptions,
 ): SpawnedJobProcess {
   const commandLine = buildCommandLine(options.command, options.args)
-  return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) =>
+  return spawnJobProcess(api, options, () => targetCarrierHandles(api, options.stdio), 'CreateProcessW', (startupInfo, processInfo) =>
     api.createProcessW(
       options.applicationName ?? null,
       commandLine,
@@ -479,87 +515,14 @@ export function spawnCurrentTokenJobProcess(
  * Verify that an unnamed kill-on-close Job can be created and released now.
  * @param api - active binding table.
  */
-export function probeCurrentTokenJobSupport(api: Win32ProcessBindings): void {
+export function probeCurrentTokenJobSupport(api: CurrentTokenProcessBindings): void {
+  if (typeof api.getOsfHandle !== 'function') {
+    throw new Error('current-token Job support requires UCRT _get_osfhandle')
+  }
   const job = createKillOnCloseJob(api)
   closeHandleChecked(api, job, 'current-token Job capability probe')
 }
 
-interface MaterializedStdioStream {
-  readonly destroyed: boolean
-  destroy(): unknown
-  _destroy?: unknown
-}
-
-/**
- * Release the runner's Node-owned standard streams after target creation.
- * The target retains its inherited handle copies. On Windows, libuv duplicates
- * fd 0/1/2 for its stream owners while Node gives stdout and stderr an own
- * no-op `_destroy`. Close the raw handles and then restore the real Socket
- * destruction path so parent pipe EOF can follow the target.
- * @param api - active binding table used to release raw standard handles.
- * @param streams - stdin, stdout, and stderr used by the current process.
- */
-export function closeCurrentProcessStandardStreams(
-  api: Win32ProcessBindings,
-  streams: readonly [MaterializedStdioStream, MaterializedStdioStream, MaterializedStdioStream] = [
-    process.stdin,
-    process.stdout,
-    process.stderr,
-  ],
-): void {
-  const failures: Error[] = []
-  const recordFailure = (error: unknown): void => {
-    failures.push(error instanceof Error ? error : new Error(String(error)))
-  }
-
-  const handles: NativePtr[] = []
-  for (const selector of [abi.STD_INPUT_HANDLE, abi.STD_OUTPUT_HANDLE, abi.STD_ERROR_HANDLE]) {
-    try {
-      const handle = api.getStdHandle(selector)
-      if (!isNullPtr(handle) && !handles.includes(handle)) handles.push(handle)
-    } catch (error) {
-      recordFailure(error)
-    }
-  }
-  for (const handle of handles) {
-    try {
-      closeHandleChecked(api, handle, 'runner standard handle')
-    } catch (error) {
-      recordFailure(error)
-    }
-  }
-
-  const [stdin, stdout, stderr] = streams
-  if (!stdin.destroyed) {
-    try {
-      stdin.destroy()
-    } catch (error) {
-      recordFailure(error)
-    }
-  }
-  for (const output of [stdout, stderr]) {
-    if (output.destroyed) continue
-    if (Object.hasOwn(output, '_destroy')) {
-      try {
-        if (!Reflect.deleteProperty(output, '_destroy')) {
-          throw new Error('deleting runner standard-stream destroy override failed')
-        }
-      } catch (error) {
-        recordFailure(error)
-      }
-    }
-    try {
-      output.destroy()
-    } catch (error) {
-      recordFailure(error)
-    }
-  }
-  if (failures.length === 1) {
-    for (const failure of failures) throw failure
-  }
-  if (failures.length > 1) throw new AggregateError(failures, 'closing runner standard streams failed')
-}
-
 /**
  * Poll one process handle without blocking the runner event loop.
  * @param api - active binding table.

+ 31 - 106
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -2,7 +2,6 @@ import koffi from 'koffi'
 import { describe, expect, it, vi } from 'vitest'
 import {
   closeHandleChecked,
-  closeCurrentProcessStandardStreams,
   isJobEmpty,
   pollProcessExit,
   probeCurrentTokenJobSupport,
@@ -15,15 +14,15 @@ import {
   JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET,
   JOBOBJECT_BASIC_ACCOUNTING_SIZE,
   JobObjectBasicAccountingInformation,
-  STD_ERROR_HANDLE,
-  STD_INPUT_HANDLE,
-  STD_OUTPUT_HANDLE,
   WAIT_TIMEOUT,
 } from '../src/abi.ts'
 import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts'
-import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
+import type {
+  CurrentTokenProcessBindings,
+  NativePtr,
+} from '../src/index.ts'
 
-function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBindings {
+function api(overrides: Partial<CurrentTokenProcessBindings> = {}): CurrentTokenProcessBindings {
   return {
     createJobObjectW: vi.fn(() => 50n),
     setInformationJobObject: vi.fn(() => 1),
@@ -32,6 +31,7 @@ function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBinding
       return 1
     }),
     getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)),
+    getOsfHandle: vi.fn((fileDescriptor: number) => BigInt(67 + fileDescriptor)),
     setHandleInformation: vi.fn(() => 1),
     createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
       koffi.encode(info, PROCESS_INFORMATION, {
@@ -55,7 +55,7 @@ function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBinding
     getLastError: vi.fn(() => 5),
     formatMessageW: vi.fn(() => 0),
     ...overrides,
-  } as unknown as Win32ProcessBindings
+  } as unknown as CurrentTokenProcessBindings
 }
 
 describe('ordinary Job process operations', () => {
@@ -88,6 +88,7 @@ describe('ordinary Job process operations', () => {
       applicationName: 'C:\\resolved\\probe.exe',
       args: ['literal $VALUE', 'a b'],
       cwd: 'C:\\work',
+      stdio: { stdin: 4, stdout: 5, stderr: 6 },
     })).toEqual({ pid: 1234, process: 60n, job: 50n })
     expect(createProcessW).toHaveBeenCalledWith(
       'C:\\resolved\\probe.exe',
@@ -110,24 +111,24 @@ describe('ordinary Job process operations', () => {
     const bindings = api({ createProcessW: vi.fn(() => 0) })
     let caught: unknown
     try {
-      spawnCurrentTokenJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' })
+      spawnCurrentTokenJobProcess(bindings, {
+        command: 'missing.exe',
+        args: [],
+        cwd: 'C:\\work',
+        stdio: { stdin: 4, stdout: 5, stderr: 6 },
+      })
     } catch (error) {
       caught = error
     }
     expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 })
   })
 
-  it('inherits the runner standard handles and restores their flags', () => {
+  it('resolves the target carrier descriptors and restores their handle flags', () => {
     let startup: Record<string, unknown> | undefined
-    const handles = new Map([
-      [STD_INPUT_HANDLE, 71n as NativePtr],
-      [STD_OUTPUT_HANDLE, 72n as NativePtr],
-      [STD_ERROR_HANDLE, 73n as NativePtr],
-    ])
-    const getStdHandle = vi.fn((selector: number) => handles.get(selector) as NativePtr)
+    const getOsfHandle = vi.fn((fileDescriptor: number) => BigInt(67 + fileDescriptor))
     const setHandleInformation = vi.fn(() => 1)
     const bindings = api({
-      getStdHandle,
+      getOsfHandle,
       setHandleInformation,
       createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => {
         startup = koffi.decode(infoPtr, STARTUPINFOW) as Record<string, unknown>
@@ -144,12 +145,9 @@ describe('ordinary Job process operations', () => {
       command: 'probe.exe',
       args: [],
       cwd: 'C:\\work',
+      stdio: { stdin: 4, stdout: 5, stderr: 6 },
     })).toEqual({ pid: 1234, process: 60n, job: 50n })
-    expect(getStdHandle.mock.calls.map(([selector]) => selector)).toEqual([
-      STD_INPUT_HANDLE,
-      STD_OUTPUT_HANDLE,
-      STD_ERROR_HANDLE,
-    ])
+    expect(getOsfHandle.mock.calls.map(([fileDescriptor]) => fileDescriptor)).toEqual([4, 5, 6])
     expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n })
     expect(setHandleInformation.mock.calls).toEqual([
       [71n, 1, 1], [72n, 1, 1], [73n, 1, 1],
@@ -213,92 +211,19 @@ describe('ordinary Job process operations', () => {
     expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
-  it('closes unique raw handles and restores output Socket destruction', () => {
-    const input = { destroyed: false, destroy: vi.fn() }
-    const socketDestroy = vi.fn()
-    const dummyDestroy = vi.fn()
-    const makeOutput = (): {
-      stream: { destroyed: boolean; destroy(): void; _destroy?: () => void }
-      destroy: ReturnType<typeof vi.fn>
-    } => {
-      const destroy = vi.fn(function (this: { _destroy(): void }) { this._destroy() })
-      const stream = Object.assign(Object.create({ _destroy: socketDestroy }), {
-        destroyed: false,
-        destroy,
-        _destroy: dummyDestroy,
-      }) as { destroyed: boolean; destroy(): void; _destroy?: () => void }
-      return { stream, destroy }
-    }
-    const { stream: stdout, destroy: stdoutDestroy } = makeOutput()
-    const { stream: stderr, destroy: stderrDestroy } = makeOutput()
+  it('rejects an unavailable carrier descriptor or CRT binding before target creation', () => {
     const closeHandle = vi.fn(() => 1)
-    const getStdHandle = vi.fn((selector: number) =>
-      (selector === STD_INPUT_HANDLE ? 10n : 11n) as NativePtr)
-    const bindings = api({
-      getStdHandle,
-      closeHandle,
-    })
-
-    expect(() => {
-      closeCurrentProcessStandardStreams(bindings, [input, stdout, stderr])
-    }).not.toThrow()
-    expect(getStdHandle).toHaveBeenCalledTimes(3)
-    expect(closeHandle.mock.calls).toEqual([[10n], [11n]])
-    expect(input.destroy).toHaveBeenCalledOnce()
-    expect(stdoutDestroy).toHaveBeenCalledOnce()
-    expect(stderrDestroy).toHaveBeenCalledOnce()
-    expect(dummyDestroy).not.toHaveBeenCalled()
-    expect(socketDestroy).toHaveBeenCalledTimes(2)
-    expect(Object.hasOwn(stdout, '_destroy')).toBe(false)
-    expect(Object.hasOwn(stderr, '_destroy')).toBe(false)
-  })
-
-  it('reports one or several runner standard-stream close failures', () => {
-    const closed = { destroyed: true, destroy: vi.fn() }
-    let getStdHandleCalls = 0
-    const getStdHandle = vi.fn(() => {
-      getStdHandleCalls += 1
-      if (getStdHandleCalls === 1) throw new Error('single close failure')
-      return 0n as NativePtr
-    })
-    const singleFailure = api({ getStdHandle })
-    expect(() => {
-      closeCurrentProcessStandardStreams(singleFailure, [
-        closed,
-        closed,
-        closed,
-      ])
-    }).toThrow('single close failure')
-    expect(getStdHandle).toHaveBeenCalledTimes(3)
+    const missingDescriptor = api({ closeHandle, getOsfHandle: vi.fn(() => -1) })
+    expect(() => spawnCurrentTokenJobProcess(missingDescriptor, {
+      command: 'probe.exe',
+      args: [],
+      cwd: 'C:\\work',
+      stdio: { stdin: 4, stdout: 5, stderr: 6 },
+    })).toThrow('_get_osfhandle failed for target stdin fd 4')
+    expect(closeHandle).toHaveBeenCalledWith(50n)
 
-    const rawCloseFailure = api({ closeHandle: vi.fn(() => 0) })
-    const nonConfigurableDestroy = vi.fn()
-    const nonConfigurable = {
-      destroyed: false,
-      destroy: nonConfigurableDestroy,
-    } as { destroyed: boolean; destroy(): void; _destroy?: unknown }
-    Object.defineProperty(nonConfigurable, '_destroy', { value: vi.fn(), configurable: false })
-    let failure: unknown
-    try {
-      closeCurrentProcessStandardStreams(rawCloseFailure, [
-        { destroyed: false, destroy: () => { throw 'input close failure' } },
-        nonConfigurable,
-        { destroyed: false, destroy: () => { throw new Error('output close failure') } },
-      ])
-    } catch (error) {
-      failure = error
-    }
-    expect(failure).toMatchObject({
-      name: 'AggregateError',
-      message: 'closing runner standard streams failed',
-    })
-    const errors = (failure as { errors: unknown }).errors
-    expect(errors).toEqual(expect.arrayContaining([
-      expect.objectContaining({ api: 'CloseHandle' }),
-      expect.objectContaining({ message: 'input close failure' }),
-      expect.objectContaining({ message: 'deleting runner standard-stream destroy override failed' }),
-      expect.objectContaining({ message: 'output close failure' }),
-    ]))
-    expect(nonConfigurableDestroy).toHaveBeenCalledOnce()
+    const missingBinding = api({ getOsfHandle: undefined as never })
+    expect(() => { probeCurrentTokenJobSupport(missingBinding) })
+      .toThrow('current-token Job support requires UCRT _get_osfhandle')
   })
 })