1
0
Эх сурвалжийг харах

test(subprocess): verify control inheritance and document channel ownership

Tianyi Cui 1 сар өмнө
parent
commit
b9dfb8da1c
27 өөрчлөгдсөн 242 нэмэгдсэн , 17 устгасан
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-11-subprocess-control-pipe.i18n.yaml
  2. 31 0
      .agents/notes/implemented/architecture/2026-09-11-subprocess-control-pipe.md
  3. 31 0
      .agents/notes/implemented/architecture/2026-09-11-subprocess-control-pipe.zh.md
  4. 2 2
      docs/subsystems/subprocess.i18n.yaml
  5. 4 0
      docs/subsystems/subprocess.md
  6. 4 0
      docs/subsystems/subprocess.zh.md
  7. 2 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  8. 2 2
      packages/sandbox/sandbox-windows-acl/README.i18n.yaml
  9. 2 0
      packages/sandbox/sandbox-windows-acl/README.md
  10. 2 0
      packages/sandbox/sandbox-windows-acl/README.zh.md
  11. 2 1
      packages/sandbox/sandbox-windows-acl/package.json
  12. 73 0
      packages/sandbox/sandbox-windows-acl/tests/control.spec.ts
  13. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  14. 4 0
      packages/subprocess/subprocess-local/README.md
  15. 4 0
      packages/subprocess/subprocess-local/README.zh.md
  16. 15 0
      packages/subprocess/subprocess-local/tests/control.spec.ts
  17. 2 2
      packages/subprocess/subprocess/README.i18n.yaml
  18. 5 0
      packages/subprocess/subprocess/README.md
  19. 5 0
      packages/subprocess/subprocess/README.zh.md
  20. 29 0
      packages/subprocess/subprocess/tests/control.spec.ts
  21. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  22. 2 0
      packages/subprocess/win32-process/README.md
  23. 2 0
      packages/subprocess/win32-process/README.zh.md
  24. 4 3
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  25. 3 0
      pnpm-lock.yaml
  26. 1 0
      scripts/check-workspace-constraints.ts
  27. 1 1
      tsconfig.base.json

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-subprocess-control-pipe.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/architecture/2026-09-11-subprocess-control-pipe.md
+2026-09-11-subprocess-control-pipe.md: b9c58842961ff96ebfd6d3f9d8be9545ff5a79e0
+2026-09-11-subprocess-control-pipe.zh.md: 10539fd783363e068abf7f15bc0579efd29e95e6

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-11-subprocess-control-pipe.md

@@ -0,0 +1,31 @@
+# Agent Note: Subprocess control pipe
+
+Status: implemented
+
+English | [中文](2026-09-11-subprocess-control-pipe.zh.md)
+
+## Problem
+
+A managed Node program can write arbitrary bytes to stdout and stderr. A host protocol sharing either stream cannot distinguish those bytes from program diagnostics without restricting ordinary Node behavior. Windows process wrappers also require explicit descriptor inheritance before the child runtime allocates its own descriptors.
+
+## Decision
+
+Ordinary subprocess requests optionally set `stdio.control: 'pipe'` and receive a raw `Duplex` as `handle.control`. The target opens fd 7 through `@deepseek-ai/dsh-subprocess/control`. The provider owns the environment marker; the child helper consumes it. Consumers own bounded framing, message validation, backpressure, and endpoint closure. Standard output collection and managed-range lifetime retain their existing semantics, and provider disposal closes any remaining endpoint after teardown.
+
+POSIX launchers preserve fd 7 across exec. Windows ordinary Job and restricted-token launchers place the pipe at slot 7 in the CRT startup descriptor table, preserve standard handles, and leave slots 3–6 closed in the payload. Each wrapper closes its carrier after transferring ownership. Handle inheritance is enabled only around process creation. This channel grants no host capability: the child remains untrusted, and every host tool request requires its usual dispatch and approval checks.
+
+The filesystem and subprocess services remain replaceable together by remote providers. Neither the public handle nor its request exposes a host path, process identifier, execution-world flag, or transport negotiation catalogue. Terminal allocation remains asynchronous and does not gain an extra descriptor.
+
+## Alternatives considered
+
+**Stdout framing.** Native code and ordinary `process.stdout.write` can emit arbitrary bytes, so protocol integrity would depend on intercepting program output.
+
+**Node IPC.** The Windows process supervisor already uses a private IPC channel. Coupling payload requests to that management protocol would expose supervisor operations and complicate remote transport.
+
+**A late Windows descriptor replacement.** Replacing fd 7 after Node starts can overwrite an internal descriptor. The CRT startup table reserves it before runtime initialization and keeps the child API identical across hosts.
+
+**A different descriptor per platform.** Per-platform numbers would add bootstrap branching without removing the native startup work. One fixed slot also allows remote providers to preserve the same child API.
+
+## Consequences
+
+Native wrappers must preserve and close one extra pipe explicitly. The subprocess service does not interpret control messages or buffer them for callers, so protocol consumers must bound their own retained input and output. The OS sandbox and managed process owner remain responsible for confinement and teardown; a dedicated transport is not a JavaScript security boundary.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-11-subprocess-control-pipe.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Subprocess control pipe
+
+Status: implemented
+
+[English](2026-09-11-subprocess-control-pipe.md) | 中文
+
+## 问题
+
+受管 Node 程序可以向 stdout 和 stderr 写入任意字节。若宿主协议共用其中任一流,就无法在不限制普通 Node 行为的前提下区分协议字节和程序诊断。Windows 进程包装器还要求在子运行时分配自身描述符前显式设置描述符继承。
+
+## 决策
+
+普通 subprocess 请求可以设置 `stdio.control: 'pipe'`,并通过 `handle.control` 收到原始 `Duplex`。目标通过 `@deepseek-ai/dsh-subprocess/control` 打开 fd 7。提供方拥有环境标记;子进程辅助函数会消费它。消费方负责有界分帧、消息校验、背压和端点关闭。标准输出收集与受管范围生命周期保留现有语义,提供方销毁时会在拆卸后关闭仍存在的端点。
+
+POSIX 启动器在 exec 时保留 fd 7。Windows 普通 Job 与受限令牌启动器把管道放入 CRT 启动描述符表的槽 7,保留标准句柄,并让负载中的槽 3–6 保持关闭。每层包装器在转移所有权后关闭自身承载端。句柄继承仅在进程创建期间启用。该通道不授予任何宿主能力:子进程仍不可信,每次宿主工具请求都需要通常的分发与审批检查。
+
+文件系统与 subprocess 服务仍可由远程提供方成对替换。公共句柄和请求均不公开宿主路径、进程标识、执行世界标志或传输协商目录。终端分配保持异步,且不增加额外描述符。
+
+## 考虑过的替代方案
+
+**Stdout 分帧。** 原生代码和普通 `process.stdout.write` 可以输出任意字节,因此协议完整性将依赖于拦截程序输出。
+
+**Node IPC。** Windows 进程监督器已经使用私有 IPC 通道。把负载请求耦合到该管理协议会暴露监督器操作,并使远程传输复杂化。
+
+**在 Windows 启动后替换描述符。** Node 启动后替换 fd 7 可能覆盖内部描述符。CRT 启动表在运行时初始化前保留它,并保持各宿主的子进程 API 一致。
+
+**各平台使用不同描述符。** 不同平台的编号会增加引导分支,却不能省去原生启动工作。固定槽位也让远程提供方可以保留相同的子进程 API。
+
+## 后果
+
+原生包装器必须显式保留并关闭一条额外管道。subprocess 服务不解释控制消息,也不替调用方缓冲消息,因此协议消费方必须自行限制保留的输入和输出。操作系统沙箱与受管进程拥有者仍负责限制和拆卸;独立传输不是 JavaScript 安全边界。

+ 2 - 2
docs/subsystems/subprocess.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 docs/subsystems/subprocess.md
-subprocess.md: b62a9c19cfdf9b557ce21cf5bcc9e7e1a01a580f
-subprocess.zh.md: d9206833c14f39ed8e8bb9687b8d06c1b51b42f7
+subprocess.md: 7e10c547b4ab8213f314829caab5ade41e873f7b
+subprocess.zh.md: aaa99dea575778a62d9517aa67e8b2aea0a9e604

+ 4 - 0
docs/subsystems/subprocess.md

@@ -83,6 +83,8 @@ interface SubprocessStdio {
   stdin: SubprocessStdinMode
   stdout: SubprocessOutputMode
   stderr: SubprocessOutputMode
+  /** Request a separate byte-mode duplex channel; omission creates none. */
+  control?: 'pipe'
 }
 ```
 
@@ -150,6 +152,8 @@ interface SubprocessHandle {
   readonly stdout: Readable | undefined
   /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */
   readonly stderr: Readable | undefined
+  /** Separate caller-owned byte channel, present exactly when requested in stdio. */
+  readonly control: Duplex | undefined
   /** Offset-based readers for collect-mode streams (also readable after exit). */
   readonly collected: SubprocessCollectedOutputs
   /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */

+ 4 - 0
docs/subsystems/subprocess.zh.md

@@ -83,6 +83,8 @@ interface SubprocessStdio {
   stdin: SubprocessStdinMode
   stdout: SubprocessOutputMode
   stderr: SubprocessOutputMode
+  /** Request a separate byte-mode duplex channel; omission creates none. */
+  control?: 'pipe'
 }
 ```
 
@@ -150,6 +152,8 @@ interface SubprocessHandle {
   readonly stdout: Readable | undefined
   /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */
   readonly stderr: Readable | undefined
+  /** Separate caller-owned byte channel, present exactly when requested in stdio. */
+  readonly control: Duplex | undefined
   /** Offset-based readers for collect-mode streams (also readable after exit). */
   readonly collected: SubprocessCollectedOutputs
   /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */

+ 2 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -5780,7 +5780,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SubprocessHandle',
-    declaration: 'export interface SubprocessHandle {\n    readonly stdin: Writable | undefined;\n    readonly stdout: Readable | undefined;\n    readonly stderr: Readable | undefined;\n    readonly collected: SubprocessCollectedOutputs;\n    readonly done: Promise<SubprocessOutcome>;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
+    declaration: 'export interface SubprocessHandle {\n    readonly stdin: Writable | undefined;\n    readonly stdout: Readable | undefined;\n    readonly stderr: Readable | undefined;\n    readonly control: Duplex | undefined;\n    readonly collected: SubprocessCollectedOutputs;\n    readonly done: Promise<SubprocessOutcome>;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
   },
   {
     name: 'SubprocessOutcome',
@@ -5808,7 +5808,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SubprocessStdio',
-    declaration: 'export interface SubprocessStdio {\n    stdin: SubprocessStdinMode;\n    stdout: SubprocessOutputMode;\n    stderr: SubprocessOutputMode;\n}',
+    declaration: 'export interface SubprocessStdio {\n    stdin: SubprocessStdinMode;\n    stdout: SubprocessOutputMode;\n    stderr: SubprocessOutputMode;\n    control?: \'pipe\';\n}',
   },
   {
     name: 'SubprocessTerminalForeground',

+ 2 - 2
packages/sandbox/sandbox-windows-acl/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/sandbox/sandbox-windows-acl/README.md
-README.md: c86c66395ce326293f93a5573e36b09eeff362d5
-README.zh.md: 527863469312bec9ea78ca21b4032f90608a866a
+README.md: 30eaea27b9f8a0ebd1691348c9e6f87706891866
+README.zh.md: eeb65e5db1044eb75e0c13c5f3c76667432dc485

+ 2 - 0
packages/sandbox/sandbox-windows-acl/README.md

@@ -107,6 +107,8 @@ node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write>
 
 The seam materializes the deterministic workspace SID's ACE standing (once per workspace per server lifetime — the reuse cache), then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A workspace equal to or containing the temp root is rejected before any grant.
 
+When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation.
+
 ### Verified boundaries
 
 - **Everyone grants remain ambient write authority** — Everyone must stay in both restricting lists (removing it breaks early DLL initialization and CNG); an external NTFS object whose DACL grants Everyone a requested write right clears both checks and stays writable under both modes.

+ 2 - 0
packages/sandbox/sandbox-windows-acl/README.zh.md

@@ -107,6 +107,8 @@ node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write>
 
 seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服务器生命周期一次——复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。工作区若等于或包含临时根目录,会在任何授权前被拒绝。
 
+启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。
+
 ### 已验证边界
 
 - **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中(移除它会破坏早期 DLL 初始化与 CNG);外部 NTFS 对象若其 DACL 向 Everyone 授予所请求的写权限,就会同时通过两次检查,并在两种模式下保持可写。

+ 2 - 1
packages/sandbox/sandbox-windows-acl/package.json

@@ -44,6 +44,7 @@
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-subprocess": "workspace:^"
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^"
   }
 }

+ 73 - 0
packages/sandbox/sandbox-windows-acl/tests/control.spec.ts

@@ -0,0 +1,73 @@
+import { Context } from '@deepseek-ai/cordis'
+import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
+import { SUBPROCESS_CONTROL_ENV } from '@deepseek-ai/dsh-subprocess/control'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+
+let ctx: Context | undefined
+let scratch: string | undefined
+
+afterEach(async () => {
+  await ctx?.fiber.dispose()
+  if (scratch !== undefined) await rm(scratch, { recursive: true, force: true })
+  ctx = undefined
+  scratch = undefined
+})
+
+describe.skipIf(process.platform !== 'win32')('managed Windows ACL control pipe', () => {
+  it('preserves binary bytes through the Job and restricted-token runners while denying writes', async () => {
+    scratch = await mkdtemp(join(tmpdir(), 'dsh-acl-control-'))
+    ctx = new Context()
+    await ctx.plugin(LocalSubprocessRuntime)
+    const runner = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
+    const helper = import.meta.resolve('@deepseek-ai/dsh-subprocess/control')
+    const program = `
+      const { openInheritedControlChannel } = await import(process.argv[1]);
+      const { writeFileSync } = await import('node:fs');
+      const channel = openInheritedControlChannel();
+      if (process.env.${SUBPROCESS_CONTROL_ENV} !== undefined) throw new Error('marker not consumed');
+      try { writeFileSync(process.argv[2], 'must be denied'); process.exit(99); }
+      catch (error) { if (error.code !== 'EPERM' && error.code !== 'EACCES') throw error; }
+      process.stdout.write('ordinary stdout\\n');
+      process.stderr.write('ordinary stderr\\n');
+      channel.on('error', error => { throw error; });
+      const chunks = []; let received = 0;
+      channel.on('data', chunk => {
+        chunks.push(chunk); received += chunk.length;
+        if (received === 262144) channel.end(Buffer.concat(chunks), () => channel.destroy());
+      });
+    `
+    const handle = ctx.subprocess.spawn({
+      argv: [process.execPath, '--import', 'tsx/esm', runner,
+        '--workspace', scratch, '--temp', scratch, '--mode', 'read-only', '--',
+        process.execPath, '--input-type=module', '-e', program, helper, join(scratch, 'denied.txt')],
+      cwd: process.cwd(),
+      stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 4096 }, control: 'pipe' },
+      graceMs: 1000,
+    })
+    const channel = handle.control
+    if (channel === undefined) throw new Error('requested control pipe is absent')
+    const input = Buffer.alloc(262144)
+    for (let index = 0; index < input.length; index++) input[index] = index % 256
+    const received = (async () => {
+      const chunks: Buffer[] = []
+      for await (const chunk of channel) chunks.push(Buffer.from(chunk))
+      return Buffer.concat(chunks)
+    })()
+    channel.write(input)
+    try {
+      expect(await received).toEqual(input)
+      expect(await handle.done).toEqual({ exitCode: 0, signal: null })
+      expect(handle.collected.stdout?.readFrom(0).text).toBe('ordinary stdout\n')
+      expect(handle.collected.stderr?.readFrom(0).text).toBe('ordinary stderr\n')
+      expect(await handle.waitForExit()).toBe(true)
+    } finally {
+      channel.destroy()
+      handle.terminate()
+      await handle.waitForExit()
+    }
+  })
+})

+ 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: 236ae18c35b4aaa633e8ab7c8e9065b3cfae1604
-README.zh.md: f2018d08c624862c032b7691b161a26ee7461e9d
+README.md: 30d61542fe67e34fa0e139a47e37c53aecf43cfc
+README.zh.md: c659d1da37f12c8fc2b717970c97b313e5073126

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

@@ -44,6 +44,10 @@ Absolute executable paths are verified; bare names resolve against the scrubbed
 
 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.
 
+### Control transport
+
+An ordinary spawn can request the [subprocess control pipe](../subprocess/README.md#using-a-control-pipe). The target receives fd 7 on every supported host. POSIX runners preserve that descriptor across `execve`; Windows Job and ACL runners establish it in the child's CRT startup table before Node initializes and close their own carrier copies after spawning. Standard streams and the runner's private management channel remain independent.
+
 ### Running terminal sessions
 
 `spawnTerminal` allocates a real PTY and bridges UTF-8 text; you can inspect and signal the current foreground process group and await one `terminate()` operation. On supported Linux hosts, the original terminal argv runs directly inside a user-systemd scope, preserving the node-pty PID, session leader, controlling terminal, foreground `inputWaiting`, and readiness while the scope owns reparented or `setsid` descendants. On fallback hosts, cleanup retains exact identities from the rooted tree and observable session but cannot recover every escaped descendant. An exact Linux input wait requires a foreground thread whose fd 0 identifies the shell's controlling terminal and whose current syscall waits on that fd; if the kernel denies the syscall probe, the higher PTY backend uses its idle inference instead. On Windows, SIGINT is delivered as a Ctrl-C input write, SIGTSTP and SIGHUP are unsupported, and teardown verifies the shell's termination through the process table because an externally killed shell may never fire the PTY exit notification.

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

@@ -44,6 +44,10 @@ kind: "package-reference"
 
 收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
 
+### 控制传输
+
+普通 spawn 可以请求 [subprocess 控制管道](../subprocess/README.zh.md#using-a-control-pipe)。目标在所有受支持的宿主上均收到 fd 7。POSIX runner 在 `execve` 时保留该描述符;Windows Job 和 ACL runner 在 Node 初始化前通过子进程的 CRT 启动表建立它,并在 spawn 后关闭自身的承载副本。标准流与 runner 的私有管理通道保持独立。
+
 ### 运行终端会话
 
 `spawnTerminal` 分配真实 PTY 并桥接 UTF-8 文本;你可以检查当前前台进程组并向其发送信号,还可以等待一次 `terminate()` 操作。在受支持的 Linux 宿主上,原始终端 argv 直接在 user-systemd scope 内运行;node-pty PID、会话 leader、控制终端、前台 `inputWaiting` 与就绪状态保持不变,而 scope 会拥有已重新设定父进程或调用 `setsid` 的后代。在 fallback 宿主上,清理会保留根进程树和可观察会话中的精确身份,但无法重新发现每个已经逃逸的后代。Linux 的精确输入等待要求前台线程的 fd 0 标识 shell 的控制终端,且线程当前的 syscall 正在等待该 fd;如果内核拒绝 syscall 探测,上层 PTY 后端会改用空闲推断。在 Windows 上,SIGINT 以 Ctrl-C 输入写入投递,SIGTSTP 与 SIGHUP 不受支持,拆卸会通过进程表验证 shell 已终止,因为被外部终止的 shell 可能永远不会触发 PTY 退出通知。

+ 15 - 0
packages/subprocess/subprocess-local/tests/control.spec.ts

@@ -24,6 +24,21 @@ afterEach(async () => {
 })
 
 describe('managed subprocess control pipe', () => {
+  it('leaves the channel absent on an ordinary spawn', async () => {
+    ctx = new Context()
+    await ctx.plugin(LocalSubprocessRuntime)
+    handle = ctx.subprocess.spawn({
+      argv: [process.execPath, '-e', 'process.stdout.write("plain")'],
+      cwd: process.cwd(),
+      stdio: { stdin: 'ignore', stdout: { maxBytes: 32 }, stderr: { maxBytes: 32 } },
+      graceMs: 1000,
+    })
+    expect(handle.control).toBeUndefined()
+    expect(await handle.done).toEqual({ exitCode: 0, signal: null })
+    expect(handle.collected.stdout?.readFrom(0).text).toBe('plain')
+    expect(await handle.waitForExit()).toBe(true)
+  })
+
   it('returns exact binary control bytes independently of stdout and stderr', async () => {
     root = await mkdtemp(join(tmpdir(), 'dsh-control-'))
     ctx = new Context()

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

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

+ 5 - 0
packages/subprocess/subprocess/README.md

@@ -60,6 +60,11 @@ const output = handle.collected.stdout?.readFrom(0)
 
 Reads are offset-based and non-consuming: a background reader and a final batch read can share one stream without stealing each other's bytes.
 
+<a id="using-a-control-pipe"></a>
+### Using a control pipe
+
+Set `stdio.control: 'pipe'` to receive a separate raw `Duplex` in `handle.control`. The Node child opens fd 7 with `openInheritedControlChannel()` from `@deepseek-ai/dsh-subprocess/control`; this helper consumes the provider-owned `DSH_SUBPROCESS_CONTROL=pipe` marker. Callers cannot supply that marker through `env`. Control bytes never enter stdout/stderr collectors. The consumer owns framing, validation, backpressure, and closing its endpoint; provider disposal destroys any endpoint remaining after process teardown. Omitting the request returns `control: undefined`. This channel is available only for ordinary processes, and carries no authority to bypass tool approval.
+
 ### Managing process lifetime
 
 Termination and waiting use one provider-managed range. `terminate()` starts the provider's documented procedure, is idempotent, and becomes a no-op after that range is empty; the request's abort signal starts the same procedure. `waitForExit()` observes the same range and resolves only after the provider proves it quiescent, so direct command completion does not hide a surviving descendant. It rejects when the selected owner can no longer prove quiescence. Providers document their native owners and weaker fallbacks; callers own deadlines, teardown ladders, and cause classification.

+ 5 - 0
packages/subprocess/subprocess/README.zh.md

@@ -60,6 +60,11 @@ const output = handle.collected.stdout?.readFrom(0)
 
 读取基于偏移量且从不消费:后台读取与最终批量读取可以共享同一条流,而不会抢走彼此的字节。
 
+<a id="using-a-control-pipe"></a>
+### 使用控制管道
+
+设置 `stdio.control: 'pipe'` 后,`handle.control` 会返回独立的原始 `Duplex`。Node 子进程通过 `@deepseek-ai/dsh-subprocess/control` 的 `openInheritedControlChannel()` 打开 fd 7;该辅助函数会消费提供方拥有的 `DSH_SUBPROCESS_CONTROL=pipe` 标记。调用方不能通过 `env` 提供该标记。控制字节不会进入 stdout/stderr 收集器。消费方负责分帧、校验、背压和关闭自身端点;提供方销毁时会在进程拆卸后销毁仍存在的端点。省略请求则返回 `control: undefined`。该通道仅适用于普通进程,不授予绕过工具审批的权限。
+
 ### 管理进程生命周期
 
 终止与等待使用同一个由提供方管理的范围。`terminate()` 会启动提供方记录的流程,具有幂等性,并在该范围为空后成为空操作;请求的中止信号会启动同一流程。`waitForExit()` 观察同一范围,只在提供方证明它完全停稳后 resolve,因此直接命令结束不会掩盖仍存活的后代。所选 owner 无法再证明完全停稳时,它会 reject。提供方记录其 native owner 与较弱 fallback;时限、拆卸阶梯与原因分类归调用方所有。

+ 29 - 0
packages/subprocess/subprocess/tests/control.spec.ts

@@ -0,0 +1,29 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const socket = vi.hoisted(() => vi.fn(function () { return {} }))
+vi.mock('node:net', () => ({ Socket: socket }))
+
+import { openInheritedControlChannel, SUBPROCESS_CONTROL_ENV } from '../src/control.ts'
+
+afterEach(() => {
+  vi.unstubAllEnvs()
+  socket.mockClear()
+})
+
+describe('inherited control channel', () => {
+  it('consumes the provider marker and opens a duplex descriptor before allowing another open', () => {
+    vi.stubEnv(SUBPROCESS_CONTROL_ENV, 'pipe')
+    openInheritedControlChannel()
+    expect(socket).toHaveBeenCalledExactlyOnceWith({ fd: 7, readable: true, writable: true, allowHalfOpen: true })
+    expect(process.env[SUBPROCESS_CONTROL_ENV]).toBeUndefined()
+    expect(() => openInheritedControlChannel()).toThrow('not inherited')
+    expect(socket).toHaveBeenCalledOnce()
+  })
+
+  it.each([undefined, 'invalid'])('rejects an absent or invalid launch marker: %s', (marker) => {
+    vi.stubEnv(SUBPROCESS_CONTROL_ENV, marker)
+    expect(() => openInheritedControlChannel()).toThrow('not inherited')
+    expect(process.env[SUBPROCESS_CONTROL_ENV]).toBeUndefined()
+    expect(socket).not.toHaveBeenCalled()
+  })
+})

+ 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: 7a4d12f7fe6ef553113e0f202886fd42e8a313a2
-README.zh.md: f66533796a1a2116dc50f1eca2abbfe5f41dc56c
+README.md: e3009bb207e0436b4d4113d7bf1ea677ab191abc
+README.zh.md: 65f834b447a2d9f4db94130fe3f7070456aab351

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

@@ -34,6 +34,8 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and
 
 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.
+
 <a id="header-verification"></a>
 ## Header verification
 

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

@@ -34,6 +34,8 @@ kind: "package-library"
 
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child 策略。
 
+- **继承控制描述符**——Job 创建接受可选的 fd-7 管道。`STARTUPINFO.cbReserved2/lpReserved2` 携带八槽 CRT 描述符表,其中包含标准句柄、关闭的槽 3–6,以及槽 7 的控制管道。该表保留到 CreateProcess 返回;临时句柄继承在成功和失败时均恢复。在 Node 启动前初始化该槽可避免覆盖 Node 已分配的描述符。
+
 <a id="header-verification"></a>
 ## 头文件验证
 

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

@@ -75,11 +75,11 @@ function api(overrides: Partial<CurrentTokenProcessBindings> = {}): CurrentToken
 }
 
 describe('ordinary Job process operations', () => {
-  it('supplies fd 7 in the child CRT startup table and releases its temporary inheritance', () => {
+  it.each([3, 2])('supplies fd 7 with standard handle type %s and releases its temporary inheritance', (standardType) => {
     let descriptorBytes: Buffer | undefined
     const flags = vi.fn(() => 1)
     const bindings = api({
-      getFileType: vi.fn(() => 3),
+      getFileType: vi.fn(handle => handle === 107n ? 3 : standardType),
       setHandleInformation: flags,
       createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, startupPointer, processInfo) => {
         const startup = koffi.decode(startupPointer, STARTUPINFOW) as { cbReserved2: number; lpReserved2: NativePtr }
@@ -93,7 +93,8 @@ describe('ordinary Job process operations', () => {
     }))).toEqual({ pid: 1234, process: 60n, job: 50n })
     const bytes = descriptorBytes as Buffer
     expect(bytes.readUInt32LE(0)).toBe(8)
-    expect([...bytes.subarray(4, 12)]).toEqual([9, 9, 9, 0, 0, 0, 0, 9])
+    const standardFlag = standardType === 3 ? 9 : 65
+    expect([...bytes.subarray(4, 12)]).toEqual([standardFlag, standardFlag, standardFlag, 0, 0, 0, 0, 9])
     expect(bytes.readBigUInt64LE(12 + 7 * 8)).toBe(107n)
     expect(bytes.readBigUInt64LE(12 + 3 * 8)).toBe(0xffff_ffff_ffff_ffffn)
     expect(flags).toHaveBeenCalledWith(107n, 1, 1)

+ 3 - 0
pnpm-lock.yaml

@@ -7588,6 +7588,9 @@ importers:
         specifier: ^3.1.0
         version: 3.1.1
     devDependencies:
+      '@deepseek-ai/dsh-subprocess-local':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess-local
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis

+ 1 - 0
scripts/check-workspace-constraints.ts

@@ -176,6 +176,7 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   '@deepseek-ai/dsh-skill-badge': ['assets'],
   // Ordinary native containment ships a path-loaded runner and its shared
   // runner chunk beside the existing node-pty permission repair.
+  '@deepseek-ai/dsh-subprocess': ['lib/control.js'],
   '@deepseek-ai/dsh-subprocess-local': [
     'lib/runner.js',
     'lib/runner-*.js',

+ 1 - 1
tsconfig.base.json

@@ -42,6 +42,7 @@
       "@deepseek-ai/dsh-invariants": ["./packages/runtime-diagnostics/invariants/src/index.ts"],
       "@deepseek-ai/dsh-tool-call-timeout-policy": ["./packages/guard/timeout-policy/src"],
       "@deepseek-ai/dsh-spill-policy/notice": ["./packages/spill/spill-policy/src/notice.ts"],
+      "@deepseek-ai/dsh-subprocess/control": ["./packages/subprocess/subprocess/src/control.ts"],
       "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"],
       "@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"],
       "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"],
@@ -401,7 +402,6 @@
       "@deepseek-ai/dsh-subagent-in-process-driver": ["./packages/subagent/subagent-in-process-driver/src"],
       "@deepseek-ai/dsh-subagent-spawn-in-process": ["./packages/subagent/subagent-spawn-in-process/src"],
       "@deepseek-ai/dsh-subprocess": ["./packages/subprocess/subprocess/src"],
-      "@deepseek-ai/dsh-subprocess/control": ["./packages/subprocess/subprocess/src/control.ts"],
       "@deepseek-ai/dsh-subprocess-local": ["./packages/subprocess/subprocess-local/src"],
       "@deepseek-ai/dsh-system-prompt": ["./packages/core/system-prompt/src"],
       "@deepseek-ai/dsh-system-prompt/invariant": ["./packages/core/system-prompt/src/invariant.ts"],