diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 07de32b014..8e598de5da 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md -2026-08-28-subprocess-native-containment.md: 937c148101388aa49aac4826120218ff4a67a0e8 -2026-08-28-subprocess-native-containment.zh.md: 156d3a84d3a3a92a605866ee94a07616d18a09ee +2026-08-28-subprocess-native-containment.md: 4d3832b969f31a24578c4cdd92eded0d11552ff0 +2026-08-28-subprocess-native-containment.zh.md: 05e2a9d6774aa1f64c43f2e743d790be0bd1a760 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 937c148101..4d3832b969 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -28,7 +28,7 @@ The ordinary target result still comes from the same child process. The PTY path ### Windows runner and Job -The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 is always a pipe; when stdin is ignored, the parent destroys its writer before sending the start request so the target receives EOF without changing descriptor positions. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the carrier streams as the ordinary handle's stdio, and user bytes never pass through IPC. +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC. The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. @@ -55,7 +55,7 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification - Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, and exactly-once PTY managed-owner cleanup. -- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, the always-piped ignored-stdin carrier, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. +- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics. - Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core. - Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index 156d3a84d3..05e2a9d677 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -28,7 +28,7 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。 ### Windows runner 与 Job -Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。fd 4 始终是 pipe;忽略 stdin 时,parent 会在发送 start request 前销毁写端,使 target 在不改变描述符位置的情况下收到 EOF。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 @@ -55,7 +55,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification - provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态,以及 PTY managed-owner 恰好一次 cleanup。 -- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、始终使用 pipe 的 ignored-stdin carrier、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 +- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。 - native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。 - 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。 diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 06e8fb4a76..db789d0ea4 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -65,6 +65,7 @@ class WindowsJobOwner implements BoundProcessOwner { constructor( private readonly runner: RunnerProcess, private readonly exited: Promise, + private readonly directResultSeen: () => boolean, private readonly failInfrastructure: (error: unknown) => void, ) { void this.exited.catch(() => {}) @@ -79,7 +80,7 @@ class WindowsJobOwner implements BoundProcessOwner { this.terminationSent = true try { this.runner.send?.({ type: 'terminate' }, (error) => { - if (error === null || !this.runner.connected) return + if (error === null || this.directResultSeen() || !this.runner.connected) return this.failInfrastructure(error) this.terminateForHostExit() }) @@ -145,7 +146,12 @@ export function launchWindowsJob( rangeExit.reject(error) } - const owner = new WindowsJobOwner(child, rangeExit.promise, failInfrastructure) + const owner = new WindowsJobOwner( + child, + rangeExit.promise, + () => resultSeen, + failInfrastructure, + ) child.on('message', (value: unknown) => { if (resultSeen) { const error = new Error('subprocess-local: Windows runner emitted more than one direct result') diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 0b20c303b4..42177a2358 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -334,7 +334,7 @@ describe('Windows parent runner contract', () => { await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw') }) - it('ignores a terminate callback error delivered after clean runner disconnect', async () => { + it('ignores a terminate callback error after a direct result while the runner is connected', async () => { const child = new FakeChild() const launched = launch(child) const handle = bindManagedProcess(spec, launched.result) @@ -346,12 +346,12 @@ describe('Windows parent runner contract', () => { await expect(handle.done).rejects.toMatchObject({ code: 'ENOENT' }) expect(child.pendingSendCallbacks).toHaveLength(1) - child.connected = false - child.emit('close', 0, null) - await expect(handle.waitForExit()).resolves.toBe(true) + expect(child.connected).toBe(true) child.deliverNextSend(new Error('late EPIPE')) await Promise.resolve() expect(child.killed).toEqual([]) + child.connected = false + child.emit('close', 0, null) await expect(handle.waitForExit()).resolves.toBe(true) })