Parcourir la source

fix(subprocess): decouple Windows target stdio

pku-xht il y a 1 mois
Parent
commit
ac69b1e0c4
30 fichiers modifiés avec 716 ajouts et 135 suppressions
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml
  2. 3 3
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
  3. 3 3
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md
  4. 1 0
      packages/shell/bash-sandbox/tests/sandbox.spec.ts
  5. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  6. 1 1
      packages/subprocess/subprocess-local/README.md
  7. 1 1
      packages/subprocess/subprocess-local/README.zh.md
  8. 9 1
      packages/subprocess/subprocess-local/src/linux-scope.ts
  9. 4 1
      packages/subprocess/subprocess-local/src/managed-owner.ts
  10. 34 15
      packages/subprocess/subprocess-local/src/runner-launch.ts
  11. 91 18
      packages/subprocess/subprocess-local/src/spawn-runner.ts
  12. 22 13
      packages/subprocess/subprocess-local/src/spawn.ts
  13. 56 18
      packages/subprocess/subprocess-local/src/windows-job.ts
  14. 140 0
      packages/subprocess/subprocess-local/src/windows-stdio.ts
  15. 1 1
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  16. 21 7
      packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts
  17. 47 3
      packages/subprocess/subprocess-local/tests/native-windows.spec.ts
  18. 14 1
      packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts
  19. 26 30
      packages/subprocess/subprocess-local/tests/windows-job.spec.ts
  20. 112 0
      packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts
  21. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  22. 1 1
      packages/subprocess/win32-process/README.md
  23. 1 1
      packages/subprocess/win32-process/README.zh.md
  24. 8 2
      packages/subprocess/win32-process/src/abi.ts
  25. 12 0
      packages/subprocess/win32-process/src/ffi.ts
  26. 2 0
      packages/subprocess/win32-process/src/index.ts
  27. 37 5
      packages/subprocess/win32-process/src/process.ts
  28. 53 1
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  29. 6 0
      packages/subprocess/win32-process/verify/abi-probe.cpp
  30. 4 3
      vitest.config.ts

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-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/bug-fix/2026-08-20-subprocess-native-containment.md
-2026-08-20-subprocess-native-containment.md: 89b6648b6edf2b2e5e84a2900417ecd5229289e3
-2026-08-20-subprocess-native-containment.zh.md: af1b3cb960e03813dcaba67c027a785f1aab2943
+2026-08-20-subprocess-native-containment.md: 708de5d2ce4431222a7f36981d2fb4a3a801c5c0
+2026-08-20-subprocess-native-containment.zh.md: 883cf84f79e5ce00afe3fa9d00097923f6b65afa

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md

@@ -12,9 +12,9 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 `LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows creates and retains a named kill-on-close Job; a local runner backed by `@deepseek-ai/dsh-win32-process` opens that Job, creates the target suspended, assigns it, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities.
 
-The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
+The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux runner report target spawn and exit facts and lets the Windows runner report target startup or failure independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
 
-Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The runner opens that Job only for suspended create, assignment, and resume; after the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes the target exit, terminates the Job, and polls `ActiveProcesses` without retaining a runner-owned copy of the target's stdio. Raw pipe EOF therefore follows the target and descendants that actually inherited the stream. Host exit closes the parent's owner handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members.
+Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The parent owns private named-pipe endpoints for every non-inherited standard stream. The runner opens only their target-side handles, opens the Job for suspended create, assignment, and resume, and closes its copies before publishing the target pid. After the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes target exit, stream EOF, Job termination, and `ActiveProcesses` through separate owned resources. Host exit closes the parent's Job handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members.
 
 When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback.
 
@@ -34,4 +34,4 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste
 
 ## Consequences
 
-Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports. The Windows runner remains only until the parent acquires direct-process observation, while the Linux runner remains until the direct target result and the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.
+Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports. The Windows runner remains only until the parent acquires direct-process observation, and it closes its target-side named-pipe handles before release; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry, short-lived private files, and per-spawn pipe names but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md

@@ -12,9 +12,9 @@ Status: implemented
 
 `LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 创建并持有 named kill-on-close Job;由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。
 
-common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
+common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux runner 报告 target spawn 与 exit facts,并让 Windows runner 报告 target startup 或 failure,两者都不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
 
-Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。runner 只为 suspended create、assignment 与 resume 打开该 Job;parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 观察 target exit、终止 Job 并轮询 `ActiveProcesses`,不再保留 runner 拥有的 target stdio 副本。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant。host exit 会关闭 parent 的 owner handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。
+Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。parent 为每条非 inherit 标准流持有 private named-pipe endpoint。runner 只打开其 target 端 handle,并打开 Job 完成 suspended create、assignment 与 resume;它在发布 target pid 前关闭自己的 pipe handle 副本。parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 分别观察 target exit、stream EOF、Job termination 与 `ActiveProcesses`。host exit 会关闭 parent 的 Job handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 
@@ -34,4 +34,4 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 
 
 ## Consequences
 
-受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒。Windows runner 只保留到 parent 取得 direct-process observation,Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。
+受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒。Windows runner 只保留到 parent 取得 direct-process observation,且它的 target 端 named-pipe handle 会在释放前关闭;Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry、短期 private files 与 per-spawn pipe name,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。

+ 1 - 0
packages/shell/bash-sandbox/tests/sandbox.spec.ts

@@ -570,6 +570,7 @@ describe('background sandbox facts', () => {
       stderr: undefined,
       collected: { stdout: emptyReader, stderr: emptyReader },
       // Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
+      // oxlint-disable-next-line typescript/prefer-promise-reject-errors
       done: Promise.reject(undefined),
       terminate: vi.fn(),
       waitForExit: async () => true,

+ 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: d62b946f274a4d3337ad7d5ced01a8d8b51924b2
-README.zh.md: 78405529cf054456496c68739bc2037ef2712111
+README.md: 12604fa74cc645340ca79b5734c34c7027b988eb
+README.zh.md: e484b98df4b8411a8b78d2710c97067ac5a2bec6

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

@@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
 
 ## Behavior
 
-- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than Job observation. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure, while the parent observes the Windows target exit separately from range lifetime; only collected pipes retain the existing bounded drain grace.
+- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, connects the target side of private parent-owned stdio pipes, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF and stdin lifetime follow the target-side handles rather than the runner process. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure, while the parent observes the Windows target exit separately from range lifetime; only collected pipes retain the existing bounded drain grace.
 - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command.
 - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
 - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).

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

@@ -6,7 +6,7 @@
 
 ## 行为
 
-- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observation。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。
+- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job、连接由 parent 持有的 private stdio pipe 的 target 端,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 与 stdin 生命周期取决于 target 端 handle,而不取决于 runner process。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。
 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。
 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。

+ 9 - 1
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -215,5 +215,13 @@ export function launchLinuxScope(
   const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child)
   const result = runnerDirectResult(child, files, closed)
   cleanupAfterRunner(files, result.direct, closed)
-  return { child, pid: result.pid, direct: result.direct, closed, owner }
+  return {
+    stdin: child.stdin,
+    stdout: child.stdout,
+    stderr: child.stderr,
+    pid: result.pid,
+    direct: result.direct,
+    closed,
+    owner,
+  }
 }

+ 4 - 1
packages/subprocess/subprocess-local/src/managed-owner.ts

@@ -1,6 +1,7 @@
 /** Minimal managed-range ownership bound to one ordinary subprocess handle. */
 
 import type { ChildProcess } from 'node:child_process'
+import type { Readable, Writable } from 'node:stream'
 import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
 
 /** Platform owner used by termination and whole-range settlement. */
@@ -13,7 +14,9 @@ export interface BoundProcessOwner {
 
 /** Platform launch facts consumed by the common stdio and result lifecycle. */
 export interface ManagedProcessLaunch {
-  child: ChildProcess
+  stdin: Writable | null
+  stdout: Readable | null
+  stderr: Readable | null
   pid: number
   direct: Promise<SubprocessOutcome>
   closed: Promise<void>

+ 34 - 15
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -41,17 +41,14 @@ export function spawnRunnerInvocation(): string[] {
 /**
  * Build wrapper stdio corresponding to the public target dispositions.
  * @param spec - target stdio request.
- * @param ipc - append a private control channel for the Windows launcher.
  * @returns child-process stdio configuration.
  */
-export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions {
-  const stdio: StdioOptions = [
+export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions {
+  return [
     spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
     spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe',
     spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe',
   ]
-  if (ipc) stdio.push('ipc')
-  return stdio
 }
 
 /**
@@ -72,6 +69,11 @@ interface RunnerHandshake {
   events: RunnerEvent[]
 }
 
+/** Startup-only result for launchers that observe the direct process elsewhere. */
+export type RunnerStartResult =
+  | { ok: true; pid: number; events: RunnerEvent[] }
+  | { ok: false; pid: -1; error: Error }
+
 /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */
 function runnerExited(child: ChildProcess, pid: number): boolean {
   if (child.exitCode !== null || child.signalCode !== null) return true
@@ -102,7 +104,9 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner
     const events = readRunnerEvents(files.eventsPath)
     const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error')
     if (terminal?.type === 'started') return { pid: terminal.pid, events }
-    if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events }
+    if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') {
+      throw deserializeSpawnError(terminal.error)
+    }
     if (child.pid === undefined) throw new Error('native subprocess runner failed to start')
     if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start')
     Atomics.wait(handshakeWait, 0, 0, 5)
@@ -110,6 +114,26 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner
   throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`)
 }
 
+/**
+ * Read only the native runner's startup result.
+ * @param child - native wrapper process.
+ * @param files - private request and result paths.
+ * @returns target pid after publication, otherwise the launch failure.
+ */
+export function runnerStart(child: ChildProcess, files: RunnerFiles): RunnerStartResult {
+  try {
+    const handshake = waitForRunnerHandshake(child, files)
+    return { ok: true, pid: handshake.pid, events: handshake.events }
+  } catch (error) {
+    cleanupRunnerFiles(files)
+    return {
+      ok: false,
+      pid: -1,
+      error: error as Error,
+    }
+  }
+}
+
 async function waitForDirectResult(
   files: RunnerFiles,
   initial: RunnerEvent[],
@@ -151,16 +175,11 @@ export function runnerDirectResult(
   pid: number
   direct: Promise<SubprocessOutcome>
 } {
-  let handshake: RunnerHandshake
-  try {
-    handshake = waitForRunnerHandshake(child, files)
-  } catch (error) {
-    cleanupRunnerFiles(files)
-    return { pid: -1, direct: Promise.resolve().then(() => { throw error }) }
-  }
+  const start = runnerStart(child, files)
+  if (!start.ok) return { pid: -1, direct: Promise.resolve().then(() => { throw start.error }) }
   return {
-    pid: handshake.pid,
-    direct: waitForDirectResult(files, handshake.events, closed),
+    pid: start.pid,
+    direct: waitForDirectResult(files, start.events, closed),
   }
 }
 

+ 91 - 18
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -4,11 +4,12 @@ import { spawn } from 'node:child_process'
 import {
   closeHandleChecked,
   loadWin32ProcessBindings,
+  openNamedPipeForStdio,
   openJobForAssignment,
   spawnOrdinaryProcessInJob,
   Win32Error,
 } from '@deepseek-ai/dsh-win32-process'
-import type { NativePtr } from '@deepseek-ai/dsh-win32-process'
+import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process'
 import {
   appendRunnerEvent,
   consumeRunnerRequest,
@@ -20,13 +21,24 @@ type RunnerArgs =
   | { mode: 'probe-node' }
   | { mode: 'probe-win32' }
   | { mode: 'node'; requestPath: string; eventsPath: string }
-  | { mode: 'win32'; requestPath: string; eventsPath: string; jobName: string }
+  | {
+    mode: 'win32'
+    requestPath: string
+    eventsPath: string
+    jobName: string
+    stdinPipe?: string
+    stdoutPipe?: string
+    stderrPipe?: string
+  }
 
 function parseArgs(argv: string[]): RunnerArgs {
   let mode: string | undefined
   let jobName: string | undefined
   let requestPath: string | undefined
   let eventsPath: string | undefined
+  let stdinPipe: string | undefined
+  let stdoutPipe: string | undefined
+  let stderrPipe: string | undefined
   for (let index = 0; index < argv.length; index += 2) {
     const key = argv[index]
     const value = argv[index + 1]
@@ -35,6 +47,9 @@ function parseArgs(argv: string[]): RunnerArgs {
     else if (key === '--job') jobName = value
     else if (key === '--request') requestPath = value
     else if (key === '--events') eventsPath = value
+    else if (key === '--stdin-pipe') stdinPipe = value
+    else if (key === '--stdout-pipe') stdoutPipe = value
+    else if (key === '--stderr-pipe') stderrPipe = value
     else throw new Error(`subprocess runner unknown argument: ${String(key)}`)
   }
   if (mode === 'probe-node' || mode === 'probe-win32') return { mode }
@@ -42,24 +57,36 @@ function parseArgs(argv: string[]): RunnerArgs {
   if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths')
   if (mode === 'win32') {
     if (jobName === undefined || jobName.length === 0) throw new Error('subprocess runner requires a Windows Job name')
-    return { mode, requestPath, eventsPath, jobName }
+    return {
+      mode,
+      requestPath,
+      eventsPath,
+      jobName,
+      ...stdinPipe === undefined ? {} : { stdinPipe },
+      ...stdoutPipe === undefined ? {} : { stdoutPipe },
+      ...stderrPipe === undefined ? {} : { stderrPipe },
+    }
   }
   return { mode, requestPath, eventsPath }
 }
 
 function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpawnError {
-  if (!(error instanceof Win32Error)) return serializeSpawnError(error)
-  const code = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267
-    ? 'ENOENT'
-    : error.win32Code === 5
-      ? 'EPERM'
-      : error.win32Code === 193
-        ? 'EFTYPE'
-        : 'UNKNOWN'
+  const serialized = serializeSpawnError(error)
+  const code = error instanceof Win32Error
+    ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267
+      ? 'ENOENT'
+      : error.win32Code === 5
+        ? 'EPERM'
+        : error.win32Code === 193
+          ? 'EFTYPE'
+          : 'UNKNOWN'
+    : serialized.code
+  if (code === undefined) return serialized
   const program = request.argv[0] as string
   return {
-    name: 'Error',
-    message: `spawn ${program} ${code}: ${error.message}`,
+    ...serialized,
+    name: serialized.name,
+    message: `spawn ${program} ${code}: ${serialized.message}`,
     code,
     syscall: `spawn ${program}`,
     path: program,
@@ -101,21 +128,64 @@ function replaceEnvironment(env: Record<string, string>): void {
   Object.assign(process.env, env)
 }
 
-async function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): Promise<void> {
+function closeStdioHandles(
+  api: ReturnType<typeof loadWin32ProcessBindings>,
+  handles: Array<{ handle: NativePtr; label: string }>,
+  reportFailure: boolean,
+): void {
+  let failure: Error | undefined
+  for (const { handle, label } of handles.splice(0)) {
+    try {
+      closeHandleChecked(api, handle, label)
+    } catch (error) {
+      failure ??= error instanceof Error ? error : new Error(String(error))
+    }
+  }
+  if (reportFailure && failure !== undefined) throw failure
+}
+
+async function runWin32(
+  request: RunnerRequest,
+  eventsPath: string,
+  jobName: string,
+  pipes: Pick<Extract<RunnerArgs, { mode: 'win32' }>, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>,
+): Promise<void> {
   replaceEnvironment(request.env)
   const api = loadWin32ProcessBindings()
   let processHandle: NativePtr | undefined
   let jobHandle: NativePtr | undefined
   let targetStarted = false
+  let targetCreationAttempted = false
+  const openedStdio: Array<{ handle: NativePtr; label: string }> = []
   try {
     if (!process.connected) throw new Error('Windows subprocess runner requires a parent IPC channel')
     const released = new Promise<void>((resolve) => { process.once('disconnect', resolve) })
-    process.chdir(request.cwd)
     jobHandle = openJobForAssignment(api, jobName)
+    const stdio: ChildStdioHandles = {}
+    for (const [key, path] of [
+      ['stdin', pipes.stdinPipe],
+      ['stdout', pipes.stdoutPipe],
+      ['stderr', pipes.stderrPipe],
+    ] as const) {
+      if (path === undefined) continue
+      const handle = openNamedPipeForStdio(api, path)
+      stdio[key] = handle
+      openedStdio.push({ handle, label: `ordinary target ${key} pipe` })
+    }
+    // Node attributes an invalid cwd to the attempted target spawn rather
+    // than exposing the launcher's internal chdir operation.
+    targetCreationAttempted = true
+    process.chdir(request.cwd)
     const [command, ...args] = request.argv
-    const spawned = spawnOrdinaryProcessInJob(api, { command: command as string, args, cwd: process.cwd() }, jobHandle)
+    const spawned = spawnOrdinaryProcessInJob(
+      api,
+      { command: command as string, args, cwd: process.cwd() },
+      jobHandle,
+      stdio,
+    )
     processHandle = spawned.process
     targetStarted = true
+    closeStdioHandles(api, openedStdio, true)
     closeHandleChecked(api, jobHandle, 'ordinary process Job assignment')
     jobHandle = undefined
     appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid })
@@ -126,10 +196,13 @@ async function runWin32(request: RunnerRequest, eventsPath: string, jobName: str
   } catch (error) {
     appendRunnerEvent(eventsPath, {
       type: targetStarted ? 'runner-error' : 'spawn-error',
-      error: targetStarted ? serializeSpawnError(error) : win32SpawnError(error, request),
+      error: targetStarted || !targetCreationAttempted
+        ? serializeSpawnError(error)
+        : win32SpawnError(error, request),
     })
     process.exitCode = 127
   } finally {
+    closeStdioHandles(api, openedStdio, false)
     if (processHandle !== undefined) {
       try { closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ }
     }
@@ -150,7 +223,7 @@ async function main(): Promise<void> {
   if (args.mode === 'node') runNode(request, args.eventsPath)
   else {
     try {
-      await runWin32(request, args.eventsPath, args.jobName)
+      await runWin32(request, args.eventsPath, args.jobName, args)
     } finally {
       if (process.connected) process.disconnect()
     }

+ 22 - 13
packages/subprocess/subprocess-local/src/spawn.ts

@@ -433,7 +433,7 @@ export function bindManagedProcess(
 ): LocalSubprocessHandle {
   validateSubprocessSpec(spec)
   const { spillDir } = prepareManagedProcessBinding(internals)
-  const child = launch.child
+  const { stdin, stdout, stderr } = launch
 
   const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect =>
     mode !== 'pipe' && mode !== 'inherit'
@@ -447,11 +447,11 @@ export function bindManagedProcess(
     stream.on('data', (chunk: Buffer) => { collector.push(chunk) })
     return collector
   }
-  const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
-  const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
+  const stdoutCollector = collectStream(outMode, stdout, 'stdout')
+  const stderrCollector = collectStream(errMode, stderr, 'stderr')
   const stopCollectors = (): void => {
-    if (stdoutCollector !== undefined) child.stdout?.destroy()
-    if (stderrCollector !== undefined) child.stderr?.destroy()
+    if (stdoutCollector !== undefined) stdout?.destroy()
+    if (stderrCollector !== undefined) stderr?.destroy()
     stdoutCollector?.seal()
     stderrCollector?.seal()
   }
@@ -501,9 +501,9 @@ export function bindManagedProcess(
 
   // Batch stdin is written and closed up front; process exit and captured
   // output remain authoritative, so write errors (EPIPE) are best-effort.
-  if (typeof stdinMode === 'object' && child.stdin !== null) {
-    child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
-    child.stdin.end(stdinMode.data)
+  if (typeof stdinMode === 'object' && stdin !== null) {
+    stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
+    stdin.end(stdinMode.data)
   }
 
   const done = new Promise<SubprocessOutcome>((resolve, reject) => {
@@ -554,10 +554,11 @@ export function bindManagedProcess(
 
   return {
     pid: launch.pid,
-    /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
-    stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined,
-    stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined,
-    stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined,
+    /* v8 ignore start -- pipe-mode streams exist on every conforming launch;
+       the null-coalesces guard an internal adapter defect only. */
+    stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined,
+    stdout: outMode === 'pipe' ? stdout ?? undefined : undefined,
+    stderr: errMode === 'pipe' ? stderr ?? undefined : undefined,
     /* v8 ignore stop */
     collected: {
       ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {},
@@ -602,5 +603,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
     internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers,
     direct,
   )
-  return bindManagedProcess(spec, { child, pid, direct, closed, owner }, binding)
+  return bindManagedProcess(spec, {
+    stdin: child.stdin,
+    stdout: child.stdout,
+    stderr: child.stderr,
+    pid,
+    direct,
+    closed,
+    owner,
+  }, binding)
 }

+ 56 - 18
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -19,13 +19,12 @@ import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts
 import { waitWithAbort } from './managed-owner.ts'
 import { childEnv } from './spawn.ts'
 import {
-  cleanupAfterRunner,
-  runnerDirectResult,
   runnerFiles,
-  runnerStdio,
+  runnerStart,
   spawnRunnerInvocation,
 } from './runner-launch.ts'
 import { cleanupRunnerFiles } from './runner-protocol.ts'
+import { createWindowsStdioBridge } from './windows-stdio.ts'
 
 const JOB_POLL_INTERVAL_MS = 10
 const PROCESS_POLL_INTERVAL_MS = 10
@@ -65,6 +64,13 @@ function releaseRunner(child: ReturnType<typeof spawn>): Error | undefined {
   }
 }
 
+function stopFailedRunner(child: ReturnType<typeof spawn>): void {
+  if (child.connected) {
+    try { child.disconnect() } catch { /* A forced stop below remains authoritative. */ }
+  }
+  try { child.kill() } catch { /* The parent-owned Job remains responsible for any target. */ }
+}
+
 function observeRunnerExit(child: ReturnType<typeof spawn>): Promise<void> {
   return new Promise((resolve) => {
     child.once('error', () => { resolve() })
@@ -203,7 +209,7 @@ class WindowsJobOwner implements BoundProcessOwner {
  * Launch one direct command through a runner into a parent-owned Job.
  * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings.
  * @param internals - injected process runner used by tests.
- * @returns wrapper streams, target outcome, and the bound Job owner.
+ * @returns parent-owned streams, target outcome, and the bound Job owner.
  */
 export function launchWindowsJob(
   spec: SubprocessSpawnSpec,
@@ -215,7 +221,8 @@ export function launchWindowsJob(
   if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty')
   /* v8 ignore next -- the native Windows suite exercises the real Job operations. */
   const operations = internals.operations ?? nativeProcessOperations()
-  const jobName = `Local\\dsh-subprocess-${randomUUID()}`
+  const launchId = randomUUID()
+  const jobName = `Local\\dsh-subprocess-${launchId}`
   const files = runnerFiles(spec)
   let job: NativePtr
   try {
@@ -224,6 +231,14 @@ export function launchWindowsJob(
     cleanupRunnerFiles(files)
     throw error
   }
+  let stdio: ReturnType<typeof createWindowsStdioBridge>
+  try {
+    stdio = createWindowsStdioBridge(spec, `\\\\.\\pipe\\dsh-subprocess-${String(process.pid)}-${launchId}`)
+  } catch (error) {
+    try { operations.closeJob(job) } catch { /* Preserve the stdio setup failure. */ }
+    cleanupRunnerFiles(files)
+    throw error
+  }
   let child: ReturnType<typeof spawn>
   try {
     child = run(command, [
@@ -236,35 +251,46 @@ export function launchWindowsJob(
       files.requestPath,
       '--events',
       files.eventsPath,
+      ...stdio.runnerArgs,
     ], {
       env: childEnv(),
-      stdio: runnerStdio(spec, true),
+      stdio: stdio.runnerStdio,
     })
   } catch (error) {
     try { operations.closeJob(job) } catch { /* Preserve the launch failure. */ }
+    stdio.dispose()
     cleanupRunnerFiles(files)
     throw error
   }
   const runnerExited = observeRunnerExit(child)
   const closed = Promise.all([
     runnerExited,
-    observeCollectedStream(spec.stdio.stdout, child.stdout),
-    observeCollectedStream(spec.stdio.stderr, child.stderr),
+    observeCollectedStream(spec.stdio.stdout, stdio.stdout),
+    observeCollectedStream(spec.stdio.stderr, stdio.stderr),
   ]).then(() => undefined)
   const owner = new WindowsJobOwner(job, operations, runnerExited)
-  const transport = runnerDirectResult(child, files, runnerExited)
-  if (transport.pid <= 0) {
-    cleanupAfterRunner(files, transport.direct, runnerExited)
-    return { child, pid: transport.pid, direct: transport.direct, closed, owner }
+  const start = runnerStart(child, files)
+  if (!start.ok) {
+    stopFailedRunner(child)
+    stdio.dispose()
+    const direct = Promise.resolve().then(() => { throw start.error })
+    return {
+      stdin: stdio.stdin,
+      stdout: stdio.stdout,
+      stderr: stdio.stderr,
+      pid: start.pid,
+      direct,
+      closed,
+      owner,
+    }
   }
   // The launcher retains its original process handle until this process opens
   // an independent one, preventing PID reuse during the ownership handoff.
-  // Its event reader then becomes intentionally irrelevant: Windows direct
-  // settlement is owned by the handle below, not by the released runner.
-  void transport.direct.catch(() => {})
+  // Windows direct settlement is owned by the handle below, not by continued
+  // runner event polling after the startup handoff.
   let direct: Promise<SubprocessOutcome>
   try {
-    direct = observeDirectProcess(transport.pid, operations)
+    direct = observeDirectProcess(start.pid, operations)
   } catch (error) {
     direct = Promise.resolve().then(() => { throw error })
   }
@@ -273,6 +299,18 @@ export function launchWindowsJob(
     void direct.catch(() => {})
     direct = Promise.resolve().then(() => { throw releaseFailure })
   }
-  cleanupAfterRunner(files, direct, runnerExited)
-  return { child, pid: transport.pid, direct, closed, owner }
+  void direct.then(
+    () => { stdio.closeInput() },
+    () => { stdio.closeInput() },
+  )
+  void runnerExited.then(() => { cleanupRunnerFiles(files) })
+  return {
+    stdin: stdio.stdin,
+    stdout: stdio.stdout,
+    stderr: stdio.stderr,
+    pid: start.pid,
+    direct,
+    closed,
+    owner,
+  }
 }

+ 140 - 0
packages/subprocess/subprocess-local/src/windows-stdio.ts

@@ -0,0 +1,140 @@
+/** Parent-owned named-pipe streams for one Windows native launch. */
+
+import { createServer } from 'node:net'
+import type { Server, Socket } from 'node:net'
+import { PassThrough } from 'node:stream'
+import type { Readable, Writable } from 'node:stream'
+import type { StdioOptions } from 'node:child_process'
+import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+
+interface PipeEndpoint {
+  readonly path: string
+  readonly stream: PassThrough
+  dispose(): void
+}
+
+/** Streams and runner arguments for one Windows launch. */
+export interface WindowsStdioBridge {
+  readonly stdin: Writable | null
+  readonly stdout: Readable | null
+  readonly stderr: Readable | null
+  readonly runnerArgs: string[]
+  readonly runnerStdio: StdioOptions
+  closeInput(): void
+  dispose(): void
+}
+
+function closeServer(server: Server): void {
+  try {
+    server.close()
+  } catch {
+    // A listen failure or an already-accepted connection can close first.
+  }
+}
+
+function createEndpoint(path: string, direction: 'input' | 'output'): PipeEndpoint {
+  const stream = new PassThrough()
+  let socket: Socket | undefined
+  let disposed = false
+  const server = createServer({ allowHalfOpen: true })
+  // Direct-result failure remains authoritative for setup errors. Keep the
+  // stream error observable without allowing an early server failure to become
+  // an unhandled process-level exception before bindManagedProcess attaches.
+  stream.on('error', () => {})
+  server.once('error', (error) => { stream.destroy(error) })
+  server.once('connection', (connection) => {
+    if (disposed) {
+      connection.destroy()
+      return
+    }
+    socket = connection
+    closeServer(server)
+    connection.once('error', (error) => { stream.destroy(error) })
+    stream.once('close', () => { connection.destroy() })
+    if (direction === 'output') {
+      connection.once('end', () => { connection.end() })
+      connection.pipe(stream)
+    } else {
+      connection.resume()
+      stream.pipe(connection)
+      connection.once('end', () => {
+        stream.unpipe(connection)
+        connection.end()
+        stream.destroy()
+      })
+      connection.once('close', () => { stream.destroy() })
+    }
+  })
+  try {
+    server.listen(path)
+  } catch (error) {
+    stream.destroy()
+    closeServer(server)
+    throw error
+  }
+  return {
+    path,
+    stream,
+    dispose() {
+      disposed = true
+      closeServer(server)
+      socket?.destroy()
+      stream.destroy()
+    },
+  }
+}
+
+/**
+ * Create private parent-owned streams whose peer handles are opened by the Windows runner.
+ * @param spec - target stdio dispositions.
+ * @param basePath - unique named-pipe base chosen by the launch owner.
+ * @returns public streams, runner arguments, and cleanup for pre-start failure.
+ */
+export function createWindowsStdioBridge(
+  spec: SubprocessSpawnSpec,
+  basePath: string,
+): WindowsStdioBridge {
+  const endpoints: PipeEndpoint[] = []
+  let stdin: PipeEndpoint | undefined
+  let stdout: PipeEndpoint | undefined
+  let stderr: PipeEndpoint | undefined
+  try {
+    if (spec.stdio.stdin !== 'ignore') {
+      stdin = createEndpoint(`${basePath}-stdin`, 'input')
+      endpoints.push(stdin)
+    }
+    if (spec.stdio.stdout !== 'inherit') {
+      stdout = createEndpoint(`${basePath}-stdout`, 'output')
+      endpoints.push(stdout)
+    }
+    if (spec.stdio.stderr !== 'inherit') {
+      stderr = createEndpoint(`${basePath}-stderr`, 'output')
+      endpoints.push(stderr)
+    }
+  } catch (error) {
+    for (const endpoint of endpoints) endpoint.dispose()
+    throw error
+  }
+  return {
+    stdin: stdin?.stream ?? null,
+    stdout: stdout?.stream ?? null,
+    stderr: stderr?.stream ?? null,
+    runnerArgs: [
+      ...stdin === undefined ? [] : ['--stdin-pipe', stdin.path],
+      ...stdout === undefined ? [] : ['--stdout-pipe', stdout.path],
+      ...stderr === undefined ? [] : ['--stderr-pipe', stderr.path],
+    ],
+    runnerStdio: [
+      'ignore',
+      spec.stdio.stdout === 'inherit' ? 'inherit' : 'ignore',
+      spec.stdio.stderr === 'inherit' ? 'inherit' : 'ignore',
+      'ipc',
+    ],
+    closeInput() {
+      stdin?.dispose()
+    },
+    dispose() {
+      for (const endpoint of endpoints) endpoint.dispose()
+    },
+  }
+}

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

@@ -264,7 +264,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       }),
       runnerInvocation: spawnRunnerInvocation(),
     })
-    expect(launch.child.pid).toBeUndefined()
+    expect(launch.pid).toBe(-1)
     await expect(launch.direct).rejects.toThrow('runner failed to start')
     await expect(launch.owner.waitForExit()).resolves.toBe(true)
     await launch.closed

+ 21 - 7
packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts

@@ -62,7 +62,9 @@ describe('managed process binding', () => {
       },
     }
     const handle = bindManagedProcess(spec(), {
-      child: wrapper,
+      stdin: wrapper.stdin,
+      stdout: wrapper.stdout,
+      stderr: wrapper.stderr,
       pid: 4242,
       direct: direct.promise,
       closed: observeChildClose(wrapper),
@@ -86,7 +88,9 @@ describe('managed process binding', () => {
     const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] })
     const signal = vi.fn()
     const handle = bindManagedProcess(spec(), {
-      child: wrapper,
+      stdin: wrapper.stdin,
+      stdout: wrapper.stdout,
+      stderr: wrapper.stderr,
       pid: 4242,
       direct: Promise.resolve({ exitCode: 0, signal: null }),
       closed: observeChildClose(wrapper),
@@ -102,7 +106,9 @@ describe('managed process binding', () => {
     })
     const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
     const handle = bindManagedProcess(spec(), {
-      child: wrapper,
+      stdin: wrapper.stdin,
+      stdout: wrapper.stdout,
+      stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       direct: direct.promise,
       closed: Promise.resolve(),
@@ -126,7 +132,9 @@ describe('managed process binding', () => {
       ...spec(1_000),
       stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
     }, {
-      child: wrapper,
+      stdin: wrapper.stdin,
+      stdout: wrapper.stdout,
+      stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       direct: direct.promise,
       closed: new Promise<void>(() => {}),
@@ -150,7 +158,9 @@ describe('managed process binding', () => {
     })
     const failure = new Error('range observation failed')
     const handle = bindManagedProcess(spec(), {
-      child: wrapper,
+      stdin: wrapper.stdin,
+      stdout: wrapper.stdout,
+      stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       direct: new Promise(() => {}),
       closed: new Promise<void>(() => {}),
@@ -173,7 +183,9 @@ describe('managed process binding', () => {
     const direct = Promise.resolve().then(() => { throw rejection })
     const signal = vi.fn()
     const handle = bindManagedProcess(spec(), {
-      child: wrapper,
+      stdin: wrapper.stdin,
+      stdout: wrapper.stdout,
+      stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       direct,
       closed: new Promise<void>(() => {}),
@@ -202,7 +214,9 @@ describe('managed process binding', () => {
     })
     const controller = new AbortController()
     const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, {
-      child: wrapper,
+      stdin: wrapper.stdin,
+      stdout: wrapper.stdout,
+      stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       direct: direct.promise,
       closed: Promise.resolve(),

+ 47 - 3
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -55,14 +55,16 @@ function cleanup(pid: number): void {
   spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
 }
 
-function directSpawnFailure(argv: string[]): Promise<NodeJS.ErrnoException> {
+type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] }
+
+function directSpawnFailure(argv: string[], cwd = scratch): Promise<SpawnFailure> {
   return new Promise((resolve, reject) => {
     try {
-      const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' })
+      const child = spawn(argv[0] as string, argv.slice(1), { cwd, stdio: 'ignore' })
       child.once('error', resolve)
       child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) })
     } catch (error) {
-      resolve(error as NodeJS.ErrnoException)
+      resolve(error as SpawnFailure)
     }
   })
 }
@@ -70,6 +72,33 @@ function directSpawnFailure(argv: string[]): Promise<NodeJS.ErrnoException> {
 const windowsNative = process.platform === 'win32' && probeWindowsJob()
 
 describe.skipIf(!windowsNative)('Windows Job native containment', () => {
+  it('keeps raw stdin writable after the launcher handoff', async () => {
+    const output = join(scratch, `stdin-${Date.now()}.txt`)
+    const script = `
+      const { writeFileSync } = require('node:fs')
+      let input = ''
+      process.stdin.setEncoding('utf8')
+      process.stdin.on('data', chunk => { input += chunk })
+      process.stdin.on('end', () => { writeFileSync(${JSON.stringify(output)}, input) })
+    `
+    const request = {
+      ...spec([process.execPath, '-e', script]),
+      stdio: { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' } as const,
+    }
+    const handle = bindManagedProcess(request, launchWindowsJob(request))
+    if (handle.stdin === undefined) throw new Error('expected piped stdin')
+    await new Promise(resolve => setTimeout(resolve, 100))
+    await new Promise<void>((resolve, reject) => {
+      handle.stdin?.end('after-handoff', (error?: Error | null) => {
+        if (error !== undefined && error !== null) reject(error)
+        else resolve()
+      })
+    })
+    await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
+    await expect(handle.waitForExit()).resolves.toBe(true)
+    expect(readFileSync(output, 'utf8')).toBe('after-handoff')
+  })
+
   it('releases raw stdout when the target closes it before exiting', async () => {
     const request = {
       ...spec([process.execPath, '-e', 'process.stdout.end(); setInterval(() => {}, 1000)']),
@@ -175,6 +204,20 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
     const missing = spec([`missing-native-target-${Date.now()}.exe`])
     const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing))
     await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' })
+    await expect(missingHandle.waitForExit()).resolves.toBe(true)
+
+    const missingCwd = join(scratch, `missing-cwd-${Date.now()}`)
+    const cwdArgv = [process.execPath, '-e', 'process.exit(0)']
+    const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd)
+    const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd }
+    const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd))
+    await expect(invalidCwdHandle.done).rejects.toMatchObject({
+      code: expectedCwd.code,
+      syscall: expectedCwd.syscall,
+      path: expectedCwd.path,
+      spawnargs: expectedCwd.spawnargs,
+    })
+    await expect(invalidCwdHandle.waitForExit()).resolves.toBe(true)
 
     const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`)
     writeFileSync(invalidExecutable, 'not a Windows executable\r\n')
@@ -182,5 +225,6 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
     const invalid = spec([invalidExecutable])
     const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid))
     await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code })
+    await expect(invalidHandle.waitForExit()).resolves.toBe(true)
   })
 })

+ 14 - 1
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -87,7 +87,6 @@ describe('spawn runner transport', () => {
 
   it('maps every target stdio disposition', () => {
     expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
-    expect(runnerStdio(spec(), true)).toEqual(['ignore', 'pipe', 'pipe', 'ipc'])
     expect(runnerStdio(spec({
       stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' },
     }))).toEqual(['pipe', 'inherit', 'inherit'])
@@ -291,6 +290,20 @@ describe('spawn runner transport', () => {
       cleanupRunnerFiles(runnerFailure)
     }
 
+    const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
+    try {
+      appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 })
+      appendRunnerEvent(afterStartFailure.eventsPath, {
+        type: 'runner-error',
+        error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' },
+      })
+      const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise<void>(() => {}))
+      expect(result.pid).toBe(456)
+      await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' })
+    } finally {
+      cleanupRunnerFiles(afterStartFailure)
+    }
+
     const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     try {
       appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 })

+ 26 - 30
packages/subprocess/subprocess-local/tests/windows-job.spec.ts

@@ -1,7 +1,6 @@
 import { spawn, spawnSync } from 'node:child_process'
 import type { ChildProcess } from 'node:child_process'
 import { EventEmitter } from 'node:events'
-import { PassThrough } from 'node:stream'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it, vi } from 'vitest'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -17,7 +16,7 @@ function spec(argv: string[]): SubprocessSpawnSpec {
   return {
     argv,
     cwd: process.cwd(),
-    stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
+    stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
     graceMs: 100,
   }
 }
@@ -279,34 +278,6 @@ describe('Windows Job runner adapter', () => {
     expect(kill).toHaveBeenCalledOnce()
   })
 
-  it('keeps collected settlement pending until the runner and collected streams close', async () => {
-    const { child } = fakeRunner(658)
-    const stdout = new PassThrough()
-    const stderr = new PassThrough()
-    Object.assign(child, { stdout, stderr })
-    let eventsPath = ''
-    const run = vi.fn((_command: string, args: readonly string[]) => {
-      eventsPath = args[args.indexOf('--events') + 1] as string
-      appendRunnerEvent(eventsPath, { type: 'started', pid: 658 })
-      return child
-    }) as unknown as typeof spawn
-    const jobs = processOperations()
-    const launch = launchWindowsJob(spec(['fake-target']), {
-      spawn: run,
-      runnerInvocation: ['fake-runner'],
-      operations: jobs.operations,
-    })
-    let closed = false
-    void launch.closed.then(() => { closed = true })
-    await new Promise(resolve => setImmediate(resolve))
-    expect(closed).toBe(false)
-    stdout.resume()
-    stderr.resume()
-    stdout.end()
-    stderr.end()
-    await expect(launch.closed).resolves.toBeUndefined()
-  })
-
   it('closes the parent Job when spawning the runner throws synchronously', () => {
     const failure = new Error('runner spawn failed')
     const jobs = processOperations()
@@ -318,6 +289,31 @@ describe('Windows Job runner adapter', () => {
     expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n)
   })
 
+  it('force-stops a runner that times out before publishing target startup', async () => {
+    const child = new EventEmitter() as ChildProcess
+    const disconnect = vi.fn(() => { Object.assign(child, { connected: false }) })
+    const kill = vi.fn(() => {
+      queueMicrotask(() => { child.emit('exit', 1, null) })
+      return true
+    })
+    Object.assign(child, { pid: process.pid, exitCode: null, signalCode: null, connected: true, disconnect, kill })
+    const jobs = processOperations()
+    const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001)
+    try {
+      const launch = launchWindowsJob(spec(['fake-target']), {
+        spawn: vi.fn(() => child) as unknown as typeof spawn,
+        runnerInvocation: ['fake-runner'],
+        operations: jobs.operations,
+      })
+      await expect(launch.direct).rejects.toThrow('did not report target start')
+      await expect(launch.owner.waitForExit()).resolves.toBe(true)
+      expect(disconnect).toHaveBeenCalledOnce()
+      expect(kill).toHaveBeenCalledOnce()
+    } finally {
+      now.mockRestore()
+    }
+  })
+
   it('passes a generated Job name to the runner and rejects an empty invocation', async () => {
     const emptyJobs = processOperations()
     expect(() => launchWindowsJob(spec(['fake-target']), {

+ 112 - 0
packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts

@@ -0,0 +1,112 @@
+import { randomUUID } from 'node:crypto'
+import { spawnSync } from 'node:child_process'
+import { once } from 'node:events'
+import { connect } from 'node:net'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { createWindowsStdioBridge } from '../src/windows-stdio.ts'
+
+function pipeBase(): string {
+  return process.platform === 'win32'
+    ? `\\\\.\\pipe\\dsh-windows-stdio-test-${randomUUID()}`
+    : join('/tmp', `dsh-windows-stdio-${randomUUID()}`)
+}
+
+function spec(): SubprocessSpawnSpec {
+  return {
+    argv: ['target'],
+    cwd: process.cwd(),
+    stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 } },
+    graceMs: 100,
+  }
+}
+
+function pathAfter(args: readonly string[], key: string): string {
+  const path = args[args.indexOf(key) + 1]
+  if (path === undefined) throw new Error(`missing ${key}`)
+  return path
+}
+
+describe('Windows parent-owned stdio bridge', () => {
+  it('binds before returning so a synchronously launched peer can connect', async () => {
+    const bridge = createWindowsStdioBridge({
+      ...spec(),
+      stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' },
+    }, pipeBase())
+    const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe')
+    const result = spawnSync(process.execPath, ['-e', `
+      const { connect } = require('node:net')
+      const socket = connect(${JSON.stringify(stdoutPath)})
+      socket.once('connect', () => {
+        socket.write('blocked-parent', () => {
+          socket.destroy()
+          process.exit(0)
+        })
+      })
+      socket.once('error', () => { process.exit(1) })
+      setTimeout(() => { process.exit(2) }, 2000)
+    `], { timeout: 5_000 })
+    expect(result.status).toBe(0)
+
+    const chunks: Buffer[] = []
+    bridge.stdout?.on('data', (chunk: Buffer) => { chunks.push(chunk) })
+    await once(bridge.stdout as NodeJS.ReadableStream, 'end')
+    expect(Buffer.concat(chunks).toString()).toBe('blocked-parent')
+    bridge.dispose()
+  })
+
+  it('moves bytes in both directions and ends output with its target-side peer', async () => {
+    const bridge = createWindowsStdioBridge(spec(), pipeBase())
+    const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe')
+    const stderrPath = pathAfter(bridge.runnerArgs, '--stderr-pipe')
+    const stdinPath = pathAfter(bridge.runnerArgs, '--stdin-pipe')
+    expect(bridge.runnerStdio).toEqual(['ignore', 'ignore', 'ignore', 'ipc'])
+    await new Promise(resolve => setImmediate(resolve))
+
+    bridge.stdin?.end('in')
+    const stdoutPeer = connect(stdoutPath)
+    const stderrPeer = connect(stderrPath)
+    const stdinPeer = connect(stdinPath)
+    await Promise.all([once(stdoutPeer, 'connect'), once(stderrPeer, 'connect'), once(stdinPeer, 'connect')])
+
+    const stdoutChunks: Buffer[] = []
+    const stderrChunks: Buffer[] = []
+    const stdinChunks: Buffer[] = []
+    bridge.stdout?.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk) })
+    bridge.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) })
+    stdinPeer.on('data', (chunk: Buffer) => { stdinChunks.push(chunk) })
+    const stdoutEnded = once(bridge.stdout as NodeJS.ReadableStream, 'end')
+    const stderrEnded = once(bridge.stderr as NodeJS.ReadableStream, 'end')
+    const stdinEnded = once(stdinPeer, 'end')
+
+    stdoutPeer.end('out')
+    stderrPeer.end('err')
+    await Promise.all([stdoutEnded, stderrEnded, stdinEnded])
+
+    expect(Buffer.concat(stdoutChunks).toString()).toBe('out')
+    expect(Buffer.concat(stderrChunks).toString()).toBe('err')
+    expect(Buffer.concat(stdinChunks).toString()).toBe('in')
+    bridge.dispose()
+  })
+
+  it('uses inherited output directly and disposes unconnected endpoints', () => {
+    const inherited = createWindowsStdioBridge({
+      ...spec(),
+      stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
+    }, pipeBase())
+    expect(inherited.stdin).toBeNull()
+    expect(inherited.stdout).toBeNull()
+    expect(inherited.stderr).toBeNull()
+    expect(inherited.runnerArgs).toEqual([])
+    expect(inherited.runnerStdio).toEqual(['ignore', 'inherit', 'inherit', 'ipc'])
+    inherited.dispose()
+
+    const pending = createWindowsStdioBridge(spec(), pipeBase())
+    pending.closeInput()
+    expect(pending.stdin?.destroyed).toBe(true)
+    pending.dispose()
+    expect(pending.stdout?.destroyed).toBe(true)
+    expect(pending.stderr?.destroyed).toBe(true)
+  })
+})

+ 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: 61c9272a24e9007e2944992db6db1211942cdc99
-README.zh.md: 64ade89692a5a6edd73fe8a9cfdf214f8cb67405
+README.md: 5d2b6dc0f669fb14962183e6d761f80ef9ca7e2b
+README.zh.md: 9799c781ec1362631801145f9b3ff3cdb096ed35

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

@@ -10,7 +10,7 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi
 - **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 primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting independently continues until `ActiveProcesses` reaches zero.
+- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job and private named-pipe endpoints, the runner opens their target-side handles, and `spawnOrdinaryProcessInJob()` applies explicit stdio, suspended creation, Job assignment, and resume through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting and parent-owned streams then continue independently until their own OS lifetimes end.
 - **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox process handle; ordinary parent-side process polling and Job accounting, termination, and 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.
 
 The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.

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

@@ -10,7 +10,7 @@
 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。
 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。
 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。
-- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 则独立持续到 `ActiveProcesses` 归零。
+- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job 与 private named-pipe endpoint,runner 打开它们的 target 端 handle,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用显式 stdio、suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 与 parent-owned stream 随后各自持续到对应 OS 生命周期结束。
 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary parent-side process polling 与 Job accounting、termination、closure 保持独立。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
 
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。

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

@@ -6,14 +6,20 @@ export const STARTF_USESTDHANDLES = 0x00000100
 export const HANDLE_FLAG_INHERIT = 0x1
 /** Infinite WaitForSingleObject timeout. */
 export const INFINITE = 0xFFFFFFFF
-/** WaitForSingleObject returned because a zero-time probe is not signalled. */
-export const WAIT_TIMEOUT = 258
 /** CreateProcess flag that prevents user code from running before resume. */
 export const CREATE_SUSPENDED = 0x4
+/** WaitForSingleObject returned because a zero-time probe is not signalled. */
+export const WAIT_TIMEOUT = 258
 /** OpenProcess right required to read limited process information. */
 export const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
 /** Standard right required to wait on a process handle. */
 export const SYNCHRONIZE = 0x00100000
+/** Read access requested for a private named-pipe client handle. */
+export const GENERIC_READ = 0x80000000
+/** Write access requested for a private named-pipe client handle. */
+export const GENERIC_WRITE = 0x40000000
+/** Open an existing named-pipe endpoint. */
+export const OPEN_EXISTING = 3
 /** GetStdHandle selector for standard input. */
 export const STD_INPUT_HANDLE = -10
 /** GetStdHandle selector for standard output. */

+ 12 - 0
packages/subprocess/win32-process/src/ffi.ts

@@ -68,6 +68,15 @@ export interface Win32ProcessBindings {
     args: null,
   ): number
   createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number
+  createFileW(
+    path: string,
+    desiredAccess: number,
+    shareMode: number,
+    securityAttributes: null,
+    creationDisposition: number,
+    flagsAndAttributes: number,
+    templateFile: null,
+  ): NativePtr
   setHandleInformation(handle: NativePtr, mask: number, flags: number): number
   createProcessAsUserW(
     token: NativePtr,
@@ -259,6 +268,9 @@ function bindings(): Win32ProcessBindings {
       'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID,
     ]),
     createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']),
+    createFileW: bind(kernel32, 'CreateFileW', PVOID, [
+      'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID,
+    ]),
     setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']),
     createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [
       PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16',

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

@@ -22,6 +22,7 @@ export {
   createKillOnCloseJob,
   drainPipe,
   isJobEmpty,
+  openNamedPipeForStdio,
   openJobForAssignment,
   openProcessForWait,
   pollProcessExit,
@@ -32,6 +33,7 @@ export {
   waitForProcessExit,
 } from './process.ts'
 export type {
+  ChildStdioHandles,
   OrdinaryProcessSpawnOptions,
   SpawnedAssignedProcess,
   SpawnedJobProcess,

+ 37 - 5
packages/subprocess/win32-process/src/process.ts

@@ -63,6 +63,13 @@ export interface OrdinaryProcessSpawnOptions {
   cwd: string
 }
 
+/** Optional explicit target standard handles; omitted entries use the caller's standard handle. */
+export interface ChildStdioHandles {
+  stdin?: NativePtr
+  stdout?: NativePtr
+  stderr?: NativePtr
+}
+
 /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */
 export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions {
   /** Restricted primary token supplied by sandbox policy. */
@@ -346,6 +353,28 @@ export function openJobForAssignment(api: Win32ProcessBindings, name: string): N
   return job
 }
 
+/**
+ * Open one private named-pipe client for target stdio.
+ * @param api - active binding table.
+ * @param path - unique parent-owned named-pipe path.
+ * @returns caller-owned connected pipe handle.
+ */
+export function openNamedPipeForStdio(api: Win32ProcessBindings, path: string): NativePtr {
+  const handle = api.createFileW(
+    path,
+    abi.GENERIC_READ + abi.GENERIC_WRITE,
+    0,
+    null,
+    abi.OPEN_EXISTING,
+    0,
+    null,
+  )
+  if (isNullPtr(handle) || (handle as bigint) === -1n || (handle as bigint) === 0xFFFFFFFFFFFFFFFFn) {
+    throwLastError(api, 'CreateFileW', path)
+  }
+  return handle
+}
+
 /**
  * Open a process for non-blocking exit observation.
  * @param api - active binding table.
@@ -363,6 +392,7 @@ function spawnJobProcess(
   api: Win32ProcessBindings,
   options: OrdinaryProcessSpawnOptions,
   job: NativePtr,
+  stdio: ChildStdioHandles,
   createName: 'CreateProcessAsUserW' | 'CreateProcessW',
   create: (startupInfo: NativePtr, processInfo: NativePtr) => number,
 ): SpawnedAssignedProcess {
@@ -372,9 +402,9 @@ function spawnJobProcess(
     const win32Code = api.getLastError()
     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 stdIn = stdio.stdin ?? getStdHandle(abi.STD_INPUT_HANDLE, 'stdin')
+  const stdOut = stdio.stdout ?? getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout')
+  const stdErr = stdio.stderr ?? getStdHandle(abi.STD_ERROR_HANDLE, 'stderr')
   const enabled: NativePtr[] = []
   let startupInfo: NativePtr | undefined
   let processInfo: NativePtr | undefined
@@ -469,7 +499,7 @@ export function spawnInheritedJobProcess(
   const commandLine = buildCommandLine(options.command, options.args)
   try {
     return {
-      ...spawnJobProcess(api, options, job, 'CreateProcessAsUserW', (startupInfo, processInfo) =>
+      ...spawnJobProcess(api, options, job, {}, 'CreateProcessAsUserW', (startupInfo, processInfo) =>
         createRestrictedProcess(
           api,
           options,
@@ -491,15 +521,17 @@ export function spawnInheritedJobProcess(
  * @param api - active binding table.
  * @param options - command, cwd, and argv.
  * @param job - caller-owned Job handle that remains open after this call.
+ * @param stdio - optional explicit handles opened for this target.
  * @returns caller-owned process handle after successful resume.
  */
 export function spawnOrdinaryProcessInJob(
   api: Win32ProcessBindings,
   options: OrdinaryProcessSpawnOptions,
   job: NativePtr,
+  stdio: ChildStdioHandles = {},
 ): SpawnedAssignedProcess {
   const commandLine = buildCommandLine(options.command, options.args)
-  return spawnJobProcess(api, options, job, 'CreateProcessW', (startupInfo, processInfo) =>
+  return spawnJobProcess(api, options, job, stdio, 'CreateProcessW', (startupInfo, processInfo) =>
     api.createProcessW(
       null,
       commandLine,

+ 53 - 1
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -4,6 +4,7 @@ import {
   closeHandleChecked,
   createKillOnCloseJob,
   isJobEmpty,
+  openNamedPipeForStdio,
   openJobForAssignment,
   openProcessForWait,
   pollProcessExit,
@@ -13,15 +14,18 @@ import {
 } from '../src/index.ts'
 import {
   CREATE_SUSPENDED,
+  GENERIC_READ,
+  GENERIC_WRITE,
   JOB_OBJECT_ASSIGN_PROCESS,
   JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET,
   JOBOBJECT_BASIC_ACCOUNTING_SIZE,
   JobObjectBasicAccountingInformation,
+  OPEN_EXISTING,
   PROCESS_QUERY_LIMITED_INFORMATION,
   SYNCHRONIZE,
   WAIT_TIMEOUT,
 } from '../src/abi.ts'
-import { PROCESS_INFORMATION } from '../src/ffi.ts'
+import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 
 function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBindings {
@@ -29,6 +33,7 @@ function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBinding
     createJobObjectW: vi.fn(() => 50n),
     openJobObjectW: vi.fn(() => 55n),
     openProcess: vi.fn(() => 60n),
+    createFileW: vi.fn(() => 70n),
     setInformationJobObject: vi.fn(() => 1),
     queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
       information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
@@ -123,6 +128,35 @@ describe('ordinary Job process operations', () => {
     expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 })
   })
 
+  it('passes explicit target stdio handles without reading caller stdio', () => {
+    let startup: Record<string, unknown> | undefined
+    const getStdHandle = vi.fn(() => 99n as NativePtr)
+    const bindings = api({
+      getStdHandle,
+      createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => {
+        startup = koffi.decode(infoPtr, STARTUPINFOW) as Record<string, unknown>
+        koffi.encode(processInfo, PROCESS_INFORMATION, {
+          hProcess: 60n,
+          hThread: 61n,
+          dwProcessId: 1234,
+          dwThreadId: 5678,
+        })
+        return 1
+      }),
+    })
+    expect(spawnOrdinaryProcessInJob(bindings, {
+      command: 'probe.exe',
+      args: [],
+      cwd: 'C:\\work',
+    }, 50n as NativePtr, {
+      stdin: 71n as NativePtr,
+      stdout: 72n as NativePtr,
+      stderr: 73n as NativePtr,
+    })).toEqual({ pid: 1234, process: 60n })
+    expect(getStdHandle).not.toHaveBeenCalled()
+    expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n })
+  })
+
   it('terminates an assigned suspended process when resume fails', () => {
     const terminateProcess = vi.fn(() => 1)
     const closeHandle = vi.fn(() => 1)
@@ -200,6 +234,24 @@ describe('ordinary Job process operations', () => {
     expect(() => openJobForAssignment(missing, 'Local\\missing-job')).toThrow(Win32Error)
   })
 
+  it('opens a private named-pipe client for target stdio', () => {
+    const createFileW = vi.fn(() => 70n as NativePtr)
+    const bindings = api({ createFileW })
+    expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-test')).toBe(70n)
+    expect(createFileW).toHaveBeenCalledWith(
+      '\\\\.\\pipe\\dsh-test',
+      GENERIC_READ + GENERIC_WRITE,
+      0,
+      null,
+      OPEN_EXISTING,
+      0,
+      null,
+    )
+
+    const invalid = api({ createFileW: vi.fn(() => -1n as NativePtr) })
+    expect(() => openNamedPipeForStdio(invalid, '\\\\.\\pipe\\missing')).toThrow(Win32Error)
+  })
+
   it('opens a direct process for parent-side exit observation', () => {
     const openProcess = vi.fn(() => 60n as NativePtr)
     const bindings = api({ openProcess })

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

@@ -21,6 +21,9 @@ int wmain()
   P(STARTF_USESTDHANDLES);
   P(HANDLE_FLAG_INHERIT);
   P(INFINITE);
+  P(GENERIC_READ);
+  P(GENERIC_WRITE);
+  P(OPEN_EXISTING);
   P(WAIT_TIMEOUT);
   P(PROCESS_QUERY_LIMITED_INFORMATION);
   P(SYNCHRONIZE);
@@ -45,6 +48,9 @@ int wmain()
   static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag");
   static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag");
   static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag");
+  static_assert(GENERIC_READ == 0x80000000, "generic read access");
+  static_assert(GENERIC_WRITE == 0x40000000, "generic write access");
+  static_assert(OPEN_EXISTING == 3, "open existing disposition");
   static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout");
   static_assert(PROCESS_QUERY_LIMITED_INFORMATION == 0x1000, "limited process query right");
   static_assert(SYNCHRONIZE == 0x100000, "synchronize right");

+ 4 - 3
vitest.config.ts

@@ -57,11 +57,12 @@ const windowsUnsupportedCoveragePackages = process.platform === 'win32'
 const windowsOnlyCoverageExclusions = process.platform !== 'win32'
   ? [
       'packages/sandbox/sandbox-windows-acl/src/**/*.ts',
-      // The koffi-backed Win32 table (Toolhelp32/GetProcessTimes/taskkill)
-      // executes only on win32; its decision logic is unit-pinned on every
-      // host through the injected-internals suites.
+      // The Win32 adapters (Koffi process inspection/Jobs and named-pipe
+      // streams) execute only on win32; their decisions are unit-pinned on
+      // every host through focused tests and injected operations.
       'packages/subprocess/subprocess-local/src/windows-inspector.ts',
       'packages/subprocess/subprocess-local/src/windows-job.ts',
+      'packages/subprocess/subprocess-local/src/windows-stdio.ts',
     ]
   : []