Forráskód Böngészése

Merge pull request #4361 from deepseek-harness/07akioni/fix-windows-shell-console

fix(subprocess): hide Windows PTC and shell console windows
07akioni 2 hete
szülő
commit
8960d2cf11
23 módosított fájl, 158 hozzáadás és 15 törlés
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.i18n.yaml
  2. 29 0
      .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md
  3. 29 0
      .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
  5. 1 1
      .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
  6. 1 1
      .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
  7. 28 1
      packages/sandbox/sandbox-windows-acl/tests/control.spec.ts
  8. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  9. 2 0
      packages/subprocess/subprocess-local/README.md
  10. 2 0
      packages/subprocess/subprocess-local/README.zh.md
  11. 1 0
      packages/subprocess/subprocess-local/src/windows-job.ts
  12. 21 0
      packages/subprocess/subprocess-local/tests/native-windows.spec.ts
  13. 1 0
      packages/subprocess/subprocess-local/tests/windows-job.spec.ts
  14. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  15. 2 0
      packages/subprocess/win32-process/README.md
  16. 2 0
      packages/subprocess/win32-process/README.zh.md
  17. 4 0
      packages/subprocess/win32-process/src/abi.ts
  18. 1 0
      packages/subprocess/win32-process/src/ffi.ts
  19. 8 4
      packages/subprocess/win32-process/src/process.ts
  20. 7 0
      packages/subprocess/win32-process/tests/fixtures/console-state.ts
  21. 1 1
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  22. 2 1
      packages/subprocess/win32-process/tests/process.spec.ts
  23. 4 0
      packages/subprocess/win32-process/verify/abi-probe.cpp

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md
+2026-09-16-windows-subprocess-console-visibility.md: 281525e960b049d5871cde9f5da5c8f928654440
+2026-09-16-windows-subprocess-console-visibility.zh.md: 04abf7154b4b384ca521b9d232c7dfb398f5ed99

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.md

@@ -0,0 +1,29 @@
+# Agent Note: Hide Windows subprocess console windows at creation
+
+Status: implemented
+
+English | [中文](2026-09-16-windows-subprocess-console-visibility.zh.md)
+
+## Problem
+
+PTC runtime and shell calls share the Windows subprocess provider. Its ordinary Job runner omits window hiding, and native targets supply standard handles without a startup visibility flag. Desktop execution can therefore flash console windows for short-lived commands.
+
+## Decision
+
+The private Node Job runner uses `windowsHide: true`. Native ordinary and restricted-token process creation supplies `STARTF_USESHOWWINDOW` and `SW_HIDE` alongside standard handles before target code runs. Console inheritance, Job assignment before resume, and pipe ownership stay intact. No operation hides an existing parent console or promises to suppress windows explicitly opened by the command.
+
+The [ACL sandbox decision](../feature/2026-08-08-windows-acl-restricted-token-sandbox.md) still owns restricted-token policy and console-isolation limits. Initial window visibility does not require adding `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` to restricted creation.
+
+## Alternatives considered
+
+**Hide only the outer runner.** Native target creation is independent of Node's launch options, so its initial visibility also needs an explicit setting.
+
+**Remove consoles from every process.** Restricted-token creation with console-isolation flags has a recorded DLL initialization failure. Startup visibility preserves the existing console attachment rules instead.
+
+**Hide the window after PowerShell starts.** A window can become visible before the script executes; creation-time settings avoid that interval.
+
+## Consequences
+
+Ordinary subprocess startup suppresses incidental console windows without changing tool output or process cleanup. Native Windows tests inspect console visibility in a descendant and in both ACL modes, alongside existing stream, control-pipe, and Job-lifetime tests. A missing console is valid; the tests do not require one to exist. Startup-parameter tests pin the creation-time guarantee that a final visibility observation alone cannot establish.
+
+Session recordings cannot observe native console windows and their transcripts are unchanged. Windows native tests own this regression; browser screenshots cannot establish the absence of desktop windows.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 在创建时隐藏 Windows 子进程控制台窗口
+
+Status: implemented
+
+[English](2026-09-16-windows-subprocess-console-visibility.md) | 中文
+
+## 问题
+
+PTC 运行时和 shell 调用共用 Windows 子进程提供方。其普通 Job runner 未设置窗口隐藏,原生目标只提供标准句柄而没有启动可见性标志。因此,Desktop 执行短命令时可能闪现控制台窗口。
+
+## 决策
+
+私有 Node Job runner 使用 `windowsHide: true`。普通和受限令牌原生进程创建在目标代码运行前,将 `STARTF_USESHOWWINDOW` 和 `SW_HIDE` 与标准句柄一起传入。控制台继承、恢复线程前分配 Job 和管道归属保持不变。任何操作都不隐藏已有父控制台,也不承诺抑制命令显式打开的窗口。
+
+[ACL 沙箱决策](../feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md) 仍负责受限令牌策略和控制台隔离限制。设置初始窗口可见性不需要向受限进程创建添加 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE`。
+
+## 考虑过的替代方案
+
+**仅隐藏外层 runner。** 原生目标创建独立于 Node 启动选项,因此也需要明确设置初始可见性。
+
+**移除所有进程的控制台。** 受限令牌配合控制台隔离标志存在已记录的 DLL 初始化失败。启动可见性设置保留现有控制台附着规则。
+
+**PowerShell 启动后再隐藏窗口。** 窗口可能在脚本执行前已经可见;创建时设置可避免这一间隔。
+
+## 后果
+
+普通子进程启动抑制附带控制台窗口,不改变工具输出或进程清理。原生 Windows 测试检查后代进程和两种 ACL 模式的控制台可见性,并配合现有流、控制管道和 Job 生命周期测试。没有控制台也是有效状态;测试不要求控制台必须存在。启动参数测试固定创建时的保证,单独观察最终可见性无法确认这一点。
+
+会话录制无法观察原生控制台窗口,转录内容也没有变化。此回归由 Windows 原生测试负责;浏览器截图无法确认桌面窗口不存在。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
-2026-08-08-windows-acl-restricted-token-sandbox.md: a70fd8839c371c11b6660229146306f7c5aa642a
-2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 892eabf17c63b7ceaa9bb8b21ac8c6e71a2f6112
+2026-08-08-windows-acl-restricted-token-sandbox.md: 9834e9b559f0a8045b0d36dd2dcfba864238996b
+2026-08-08-windows-acl-restricted-token-sandbox.zh.md: d349b504ca6a1054237d5ca6aa61547c2a0dcdad

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md

@@ -32,7 +32,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested;
 
 
 ## Consequences
 ## Consequences
 
 
-Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
+Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
 
 
 ## Testing
 ## Testing
 
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md

@@ -32,7 +32,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
 
 
 ## 后果
 ## 后果
 
 
-所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
+所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
 
 
 ## 测试
 ## 测试
 
 

+ 28 - 1
packages/sandbox/sandbox-windows-acl/tests/control.spec.ts

@@ -1,7 +1,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import { Context } from '@deepseek-ai/cordis'
 import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
 import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
 import { SUBPROCESS_CONTROL_ENV } from '@deepseek-ai/dsh-subprocess/control'
 import { SUBPROCESS_CONTROL_ENV } from '@deepseek-ai/dsh-subprocess/control'
-import { mkdtemp, rm } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
@@ -18,6 +18,33 @@ afterEach(async () => {
 })
 })
 
 
 describe.skipIf(process.platform !== 'win32')('managed Windows ACL control pipe', () => {
 describe.skipIf(process.platform !== 'win32')('managed Windows ACL control pipe', () => {
+  it.each(['read-only', 'workspace-write'])('runs without a visible console in %s mode', async (mode) => {
+    scratch = await mkdtemp(join(tmpdir(), 'dsh-acl-console-'))
+    const workspace = join(scratch, 'workspace')
+    const temp = join(scratch, 'temp')
+    await mkdir(workspace)
+    await mkdir(temp)
+    ctx = new Context()
+    await ctx.plugin(LocalSubprocessRuntime)
+    const runner = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
+    const fixture = fileURLToPath(new URL('../../../subprocess/win32-process/tests/fixtures/console-state.ts', import.meta.url))
+    const handle = ctx.subprocess.spawn({
+      argv: [process.execPath, '--import', 'tsx/esm', runner,
+        '--workspace', workspace, '--temp', temp, '--mode', mode, '--', process.execPath, fixture],
+      cwd: process.cwd(),
+      stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 4096 } },
+      graceMs: 1000,
+    })
+    try {
+      expect(await handle.done).toEqual({ exitCode: 0, signal: null })
+      expect(handle.collected.stderr?.readFrom(0).text).toBe('')
+      expect(JSON.parse(handle.collected.stdout?.readFrom(0).text ?? '')).toMatchObject({ visible: false })
+    } finally {
+      handle.terminate()
+      await handle.waitForExit()
+    }
+  })
+
   it('preserves binary bytes through the Job and restricted-token runners while denying writes', async () => {
   it('preserves binary bytes through the Job and restricted-token runners while denying writes', async () => {
     scratch = await mkdtemp(join(tmpdir(), 'dsh-acl-control-'))
     scratch = await mkdtemp(join(tmpdir(), 'dsh-acl-control-'))
     ctx = new Context()
     ctx = new Context()

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
-README.md: 224634dc5493dba89bccc864fb8e2d2cd1ea9fe1
-README.zh.md: 439aedeac3ccaa4331a1c7e1a2c4b7092bce8384
+README.md: 24480565de2f324a451dfdb40101eb50703b30b6
+README.zh.md: c86a7eb930834d960fa25dd7b5d175af63d0b31a

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

@@ -40,6 +40,8 @@ Load the provider in the same composition as its consumers. It has no config fie
 
 
 Absolute executable paths are verified; bare names resolve against the scrubbed PATH with platform-aware executable extensions (`.COM`/`.EXE`/`.BAT`/`.CMD` on Windows). Relative paths containing separators are rejected — provide an absolute path or a bare PATH name — and relative PATH entries resolve from the host process cwd.
 Absolute executable paths are verified; bare names resolve against the scrubbed PATH with platform-aware executable extensions (`.COM`/`.EXE`/`.BAT`/`.CMD` on Windows). Relative paths containing separators are rejected — provide an absolute path or a bare PATH name — and relative PATH entries resolve from the host process cwd.
 
 
+Windows ordinary subprocesses start the private Job runner with `windowsHide` and request hidden initial windows for native targets. Standard streams and Job ownership remain independent of window visibility; commands that explicitly create their own windows are outside this guarantee.
+
 ### Collecting output
 ### Collecting output
 
 
 Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.
 Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.

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

@@ -40,6 +40,8 @@ kind: "package-reference"
 
 
 绝对可执行文件路径会被验证;裸名称根据清理后的 PATH 并以平台感知的可执行文件扩展名(Windows 上为 `.COM`/`.EXE`/`.BAT`/`.CMD`)解析。含分隔符的相对路径会被拒绝——请提供绝对路径或裸 PATH 名称——相对 PATH 条目从宿主进程 cwd 解析。
 绝对可执行文件路径会被验证;裸名称根据清理后的 PATH 并以平台感知的可执行文件扩展名(Windows 上为 `.COM`/`.EXE`/`.BAT`/`.CMD`)解析。含分隔符的相对路径会被拒绝——请提供绝对路径或裸 PATH 名称——相对 PATH 条目从宿主进程 cwd 解析。
 
 
+Windows 普通子进程通过 `windowsHide` 启动私有 Job runner,并为原生目标请求隐藏初始窗口。标准流和 Job 归属不依赖窗口可见性;显式创建自身窗口的命令不在此保证范围内。
+
 ### 收集输出
 ### 收集输出
 
 
 收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
 收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。

+ 1 - 0
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -138,6 +138,7 @@ export function launchWindowsJob(
       ...spec.argv,
       ...spec.argv,
     ], {
     ], {
       cwd: process.cwd(),
       cwd: process.cwd(),
+      windowsHide: true,
       env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation),
       env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation),
       stdio: runnerStdio(spec, true, ignoredStdinFd ?? 'pipe'),
       stdio: runnerStdio(spec, true, ignoredStdinFd ?? 'pipe'),
     }) as RunnerProcess
     }) as RunnerProcess

+ 21 - 0
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'
 import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import { afterAll, describe, expect, it } from 'vitest'
 import { afterAll, describe, expect, it } from 'vitest'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import { targetEnvironment } from '../src/runner-launch.ts'
 import { targetEnvironment } from '../src/runner-launch.ts'
@@ -84,6 +85,26 @@ function directSpawnFailure(argv: readonly string[], cwd = scratch): Promise<Spa
 const windowsNative = process.platform === 'win32' && probeWindowsJob()
 const windowsNative = process.platform === 'win32' && probeWindowsJob()
 
 
 describe.skipIf(!windowsNative)('Windows Job native containment', () => {
 describe.skipIf(!windowsNative)('Windows Job native containment', () => {
+  it('keeps ordinary descendants free of visible console windows', async () => {
+    const fixture = fileURLToPath(new URL('../../win32-process/tests/fixtures/console-state.ts', import.meta.url))
+    const script = `
+      const { spawnSync } = require('node:child_process')
+      const child = spawnSync(process.execPath, [process.argv[1]], { stdio: 'inherit' })
+      if (child.error) throw child.error
+      process.exitCode = child.status ?? 1
+    `
+    const request = spec([process.execPath, '-e', script, fixture])
+    const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request)))
+    try {
+      expect(await handle.done).toEqual({ exitCode: 0, signal: null })
+      expect(handle.collected.stderr?.readFrom(0).text).toBe('')
+      expect(JSON.parse(handle.collected.stdout?.readFrom(0).text ?? '')).toMatchObject({ visible: false })
+    } finally {
+      handle.terminate()
+      await handle.waitForExit()
+    }
+  })
+
   it('keeps raw stdin writable while the runner starts the target', async () => {
   it('keeps raw stdin writable while the runner starts the target', async () => {
     const output = join(scratch, `stdin-${Date.now()}.txt`)
     const output = join(scratch, `stdin-${Date.now()}.txt`)
     const script = `
     const script = `

+ 1 - 0
packages/subprocess/subprocess-local/tests/windows-job.spec.ts

@@ -182,6 +182,7 @@ describe('Windows parent runner contract', () => {
       'C:\\runner.js', '--', 'tool.exe', 'literal arg',
       'C:\\runner.js', '--', 'tool.exe', 'literal arg',
     ], expect.objectContaining({
     ], expect.objectContaining({
       cwd: process.cwd(),
       cwd: process.cwd(),
+      windowsHide: true,
       stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2],
       stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2],
     }))
     }))
     expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' } }])
     expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' } }])

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md
 #   pnpm run verify-translation-pairing --write packages/subprocess/win32-process/README.md
-README.md: e3009bb207e0436b4d4113d7bf1ea677ab191abc
-README.zh.md: 65f834b447a2d9f4db94130fe3f7070456aab351
+README.md: 155ca3807791a83fef437af67312b3480ce7d2a2
+README.zh.md: f0c4439af27ef45c88d73bcba54851d789da063d

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

@@ -32,6 +32,8 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and
 - **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.
 - **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.
 - **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.
 
 
+Process creation sets `STARTF_USESHOWWINDOW` with `SW_HIDE` before target code runs. It preserves console inheritance and does not add `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE`, which can fail DLL initialization under the restricted token. Existing parent console windows are not hidden.
+
 The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.
 The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.
 
 
 - **Inherited control descriptor** — Job creation accepts an optional fd-7 pipe. `STARTUPINFO.cbReserved2/lpReserved2` carries an eight-slot CRT descriptor table with standard handles, closed slots 3–6, and the control pipe at slot 7. The table is allocated until CreateProcess returns; temporary handle inheritance is reset on success and failure. Initializing the slot before Node starts avoids overwriting descriptors Node has already allocated.
 - **Inherited control descriptor** — Job creation accepts an optional fd-7 pipe. `STARTUPINFO.cbReserved2/lpReserved2` carries an eight-slot CRT descriptor table with standard handles, closed slots 3–6, and the control pipe at slot 7. The table is allocated until CreateProcess returns; temporary handle inheritance is reset on success and failure. Initializing the slot before Node starts avoids overwriting descriptors Node has already allocated.

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

@@ -32,6 +32,8 @@ kind: "package-library"
 - **ordinary 结算操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与句柄关闭使 runner 保持唯一 native owner。
 - **ordinary 结算操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与句柄关闭使 runner 保持唯一 native owner。
 - **显式结算归属** — `waitForProcessExit()` 等待并关闭沙箱 process 句柄;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回句柄。
 - **显式结算归属** — `waitForProcessExit()` 等待并关闭沙箱 process 句柄;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回句柄。
 
 
+进程创建在目标代码运行前设置 `STARTF_USESHOWWINDOW` 和 `SW_HIDE`。它保留控制台继承,不添加可能导致受限令牌下 DLL 初始化失败的 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE`。已有的父进程控制台窗口不会被隐藏。
+
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child 策略。
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child 策略。
 
 
 - **继承控制描述符**——Job 创建接受可选的 fd-7 管道。`STARTUPINFO.cbReserved2/lpReserved2` 携带八槽 CRT 描述符表,其中包含标准句柄、关闭的槽 3–6,以及槽 7 的控制管道。该表保留到 CreateProcess 返回;临时句柄继承在成功和失败时均恢复。在 Node 启动前初始化该槽可避免覆盖 Node 已分配的描述符。
 - **继承控制描述符**——Job 创建接受可选的 fd-7 管道。`STARTUPINFO.cbReserved2/lpReserved2` 携带八槽 CRT 描述符表,其中包含标准句柄、关闭的槽 3–6,以及槽 7 的控制管道。该表保留到 CreateProcess 返回;临时句柄继承在成功和失败时均恢复。在 Node 启动前初始化该槽可避免覆盖 Node 已分配的描述符。

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

@@ -2,6 +2,10 @@
 
 
 /** STARTUPINFOW uses the standard input, output, and error handles. */
 /** STARTUPINFOW uses the standard input, output, and error handles. */
 export const STARTF_USESTDHANDLES = 0x00000100
 export const STARTF_USESTDHANDLES = 0x00000100
+/** STARTUPINFOW applies wShowWindow when creating a console window. */
+export const STARTF_USESHOWWINDOW = 0x00000001
+/** Initial window visibility that preserves the child's console attachment. */
+export const SW_HIDE = 0
 /** HandleInformation flag that permits child inheritance. */
 /** HandleInformation flag that permits child inheritance. */
 export const HANDLE_FLAG_INHERIT = 0x1
 export const HANDLE_FLAG_INHERIT = 0x1
 /** Infinite WaitForSingleObject timeout. */
 /** Infinite WaitForSingleObject timeout. */

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

@@ -38,6 +38,7 @@ export function isNullPtr(value: NativePtr | null | undefined): value is null |
 export interface StartupInfoInput {
 export interface StartupInfoInput {
   cb: number
   cb: number
   dwFlags: number
   dwFlags: number
+  wShowWindow: number
   hStdInput: NativePtr
   hStdInput: NativePtr
   hStdOutput: NativePtr
   hStdOutput: NativePtr
   hStdError: NativePtr
   hStdError: NativePtr

+ 8 - 4
packages/subprocess/win32-process/src/process.ts

@@ -201,6 +201,7 @@ function createRestrictedProcess(
 
 
 /**
 /**
  * Spawn a process with anonymous-pipe stdout/stderr and immediate stdin EOF.
  * Spawn a process with anonymous-pipe stdout/stderr and immediate stdin EOF.
+ * New console windows start hidden without changing console inheritance.
  * @param api - active binding table.
  * @param api - active binding table.
  * @param options - command, cwd, args, and restricted primary token.
  * @param options - command, cwd, args, and restricted primary token.
  * @returns caller-owned process and pipe read handles.
  * @returns caller-owned process and pipe read handles.
@@ -228,7 +229,8 @@ export function spawnPipedProcess(
     startupInfo = allocStartupInfo()
     startupInfo = allocStartupInfo()
     encodeStartupInfo(startupInfo, {
     encodeStartupInfo(startupInfo, {
       cb: abi.STARTUPINFOW_SIZE,
       cb: abi.STARTUPINFOW_SIZE,
-      dwFlags: abi.STARTF_USESTDHANDLES,
+      dwFlags: abi.STARTF_USESTDHANDLES | abi.STARTF_USESHOWWINDOW,
+      wShowWindow: abi.SW_HIDE,
       hStdInput: stdIn.read,
       hStdInput: stdIn.read,
       hStdOutput: stdOut.write,
       hStdOutput: stdOut.write,
       hStdError: stdErr.write,
       hStdError: stdErr.write,
@@ -449,7 +451,9 @@ function spawnJobProcess(
     startupInfo = allocStartupInfo()
     startupInfo = allocStartupInfo()
     encodeStartupInfo(startupInfo, {
     encodeStartupInfo(startupInfo, {
       cb: abi.STARTUPINFOW_SIZE,
       cb: abi.STARTUPINFOW_SIZE,
-      dwFlags: abi.STARTF_USESTDHANDLES,
+      // Preserve console inheritance: CREATE_NO_WINDOW can fail restricted-token DLL initialization.
+      dwFlags: abi.STARTF_USESTDHANDLES | abi.STARTF_USESHOWWINDOW,
+      wShowWindow: abi.SW_HIDE,
       hStdInput: stdio.stdin,
       hStdInput: stdio.stdin,
       hStdOutput: stdio.stdout,
       hStdOutput: stdio.stdout,
       hStdError: stdio.stderr,
       hStdError: stdio.stderr,
@@ -516,7 +520,7 @@ function spawnJobProcess(
 }
 }
 
 
 /**
 /**
- * Spawn a restricted-token process suspended, assign its Job, then resume it.
+ * Spawn a restricted-token process suspended with hidden initial windows, assign its Job, then resume it.
  * @param api - active binding table.
  * @param api - active binding table.
  * @param options - command, cwd, args, and restricted primary token.
  * @param options - command, cwd, args, and restricted primary token.
  * @returns caller-owned process and Job handles after successful resume.
  * @returns caller-owned process and Job handles after successful resume.
@@ -542,7 +546,7 @@ export function spawnInheritedJobProcess(
 }
 }
 
 
 /**
 /**
- * Spawn an ordinary process suspended, assign its Job, then resume it.
+ * Spawn an ordinary process suspended with hidden initial windows, assign its Job, then resume it.
  * @param api - active binding table.
  * @param api - active binding table.
  * @param options - command, cwd, argv, and target carrier descriptors.
  * @param options - command, cwd, argv, and target carrier descriptors.
  * @returns caller-owned process and Job handles after successful resume.
  * @returns caller-owned process and Job handles after successful resume.

+ 7 - 0
packages/subprocess/win32-process/tests/fixtures/console-state.ts

@@ -0,0 +1,7 @@
+/** Reports the inherited console's visibility from a real Windows child. */
+import koffi from 'koffi'
+
+const getConsoleWindow = koffi.load('kernel32.dll').func('void * __stdcall GetConsoleWindow()')
+const isWindowVisible = koffi.load('user32.dll').func('int __stdcall IsWindowVisible(void *)')
+const window = getConsoleWindow() as bigint | null
+process.stdout.write(JSON.stringify({ attached: window !== null, visible: window !== null && isWindowVisible(window) !== 0 }))

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

@@ -201,7 +201,7 @@ describe('ordinary Job process operations', () => {
       }),
       }),
     })
     })
     expect(spawnCurrentTokenJobProcess(bindings, options())).toEqual({ pid: 1234, process: 60n, job: 50n })
     expect(spawnCurrentTokenJobProcess(bindings, options())).toEqual({ pid: 1234, process: 60n, job: 50n })
-    expect(startup).toMatchObject({ hStdInput: 104n, hStdOutput: 105n, hStdError: 106n })
+    expect(startup).toMatchObject({ dwFlags: 0x101, wShowWindow: 0, hStdInput: 104n, hStdOutput: 105n, hStdError: 106n })
     expect(uvGetOsfhandle).toHaveBeenNthCalledWith(1, 4)
     expect(uvGetOsfhandle).toHaveBeenNthCalledWith(1, 4)
     expect(uvGetOsfhandle).toHaveBeenNthCalledWith(2, 5)
     expect(uvGetOsfhandle).toHaveBeenNthCalledWith(2, 5)
     expect(uvGetOsfhandle).toHaveBeenNthCalledWith(3, 6)
     expect(uvGetOsfhandle).toHaveBeenNthCalledWith(3, 6)

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

@@ -7,7 +7,7 @@ import {
   spawnPipedProcess,
   spawnPipedProcess,
 } from '../src/index.ts'
 } from '../src/index.ts'
 import { CREATE_SUSPENDED } from '../src/abi.ts'
 import { CREATE_SUSPENDED } from '../src/abi.ts'
-import { processInformationType } from '../src/ffi.ts'
+import { processInformationType, startupInfoType } from '../src/ffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 
 
 const PVOID = koffi.pointer('void')
 const PVOID = koffi.pointer('void')
@@ -23,6 +23,7 @@ function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
   const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] =
   const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] =
     overrides.createProcessAsUserW
     overrides.createProcessAsUserW
     ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
     ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
+      expect(koffi.decode(_startup, startupInfoType())).toMatchObject({ dwFlags: 0x101, wShowWindow: 0 })
       events.push('create')
       events.push('create')
       koffi.encode(info, processInformationType(), {
       koffi.encode(info, processInformationType(), {
         hProcess: 60n,
         hProcess: 60n,

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

@@ -20,6 +20,8 @@ int wmain()
   P(CREATE_SUSPENDED);
   P(CREATE_SUSPENDED);
   P(CREATE_UNICODE_ENVIRONMENT);
   P(CREATE_UNICODE_ENVIRONMENT);
   P(STARTF_USESTDHANDLES);
   P(STARTF_USESTDHANDLES);
+  P(STARTF_USESHOWWINDOW);
+  P(SW_HIDE);
   P(HANDLE_FLAG_INHERIT);
   P(HANDLE_FLAG_INHERIT);
   P(INFINITE);
   P(INFINITE);
   P(WAIT_TIMEOUT);
   P(WAIT_TIMEOUT);
@@ -45,6 +47,8 @@ int wmain()
   static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag");
   static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag");
   static_assert(CREATE_UNICODE_ENVIRONMENT == 0x400, "Unicode environment flag");
   static_assert(CREATE_UNICODE_ENVIRONMENT == 0x400, "Unicode environment flag");
   static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag");
   static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag");
+  static_assert(STARTF_USESHOWWINDOW == 0x1, "initial window visibility flag");
+  static_assert(SW_HIDE == 0, "hidden window value");
   static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag");
   static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag");
   static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout");
   static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout");
   static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size");
   static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size");