From 6697b134b2050314d8838b2c08a8936269988372 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 7 Sep 2026 03:08:11 +0900 Subject: [PATCH 1/2] fix(coding-agent): create the win32 internal RPC socket dir recursively (fixes #1370) runHostSupervisor() calls createInternalSocketPath(paths.dir) with paths.dir set to /rpc-host-daemon. On win32 that directory was created with mkdir(..., { recursive: false }), which requires rpc-host-daemon to already exist. ensureHost() creates it before spawning, but a direct '--internal-rpc-host-supervisor' launch does not, so on a fresh Windows profile the supervisor died during bootstrap with ENOENT: no such file or directory, mkdir '\\rpc-host-daemon\\internal-'. The win32 branch now creates the directory recursively. createInternalSocketPath is exported and takes an injectable platform, mirroring spawnableChildLaunch in the same module, so the win32 bootstrap path is covered from any host. The posix branch is unchanged: it roots the directory in the OS temp dir, which always exists. --- packages/coding-agent/CHANGELOG.md | 2 + .../src/modes/rpc/host-lifecycle.ts | 11 ++++-- .../test/rpc-host-lifecycle.test.ts | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 93eababc8a..d5179a2f72 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- The Windows RPC host supervisor now creates its internal socket directory recursively, so launching `--internal-rpc-host-supervisor` directly on a fresh profile no longer crashes with `ENOENT ... mkdir '\rpc-host-daemon\internal-'` ([#1370](https://github.com/code-yeongyu/senpi/issues/1370)) + ### Removed ## [2026.9.6] - 2026-09-06 diff --git a/packages/coding-agent/src/modes/rpc/host-lifecycle.ts b/packages/coding-agent/src/modes/rpc/host-lifecycle.ts index cb2b761983..9af19c5fc2 100644 --- a/packages/coding-agent/src/modes/rpc/host-lifecycle.ts +++ b/packages/coding-agent/src/modes/rpc/host-lifecycle.ts @@ -101,13 +101,18 @@ const CHILD_WATCH_FD = 3; * The internal hop must stay short enough for sun_path (104 bytes on macOS) * regardless of where the public socket lives, and private against other local * users, so it gets its own 0700 directory under the OS temp directory. + * + * On win32 the directory lives under the caller-supplied rpc-host-daemon + * directory, which ensureHost() creates but a direct --internal-rpc-host-supervisor + * launch does not, so the parent is created recursively. */ -async function createInternalSocketPath( +export async function createInternalSocketPath( baseDir = tmpdir(), + platform: NodeJS.Platform = process.platform, ): Promise<{ socket: string; dir?: string; secretPath?: string }> { - if (process.platform === "win32") { + if (platform === "win32") { const dir = join(baseDir, `internal-${randomUUID()}`); - await mkdir(dir, { recursive: false, mode: 0o700 }); + await mkdir(dir, { recursive: true, mode: 0o700 }); return { socket: `\\\\.\\pipe\\senpi-rpc-internal-${randomUUID()}`, dir, diff --git a/packages/coding-agent/test/rpc-host-lifecycle.test.ts b/packages/coding-agent/test/rpc-host-lifecycle.test.ts index c7384d70c5..c8b25588c7 100644 --- a/packages/coding-agent/test/rpc-host-lifecycle.test.ts +++ b/packages/coding-agent/test/rpc-host-lifecycle.test.ts @@ -20,6 +20,7 @@ import { VERSION } from "../src/config.ts"; import { processIsLive, processMatchesPidFile, readProcessStartTime } from "../src/modes/app-server/daemon/process.ts"; import { createHostDaemonPaths, ensureHost, type HostLifecyclePolicyInput } from "../src/modes/rpc/host-ensure.ts"; import { + createInternalSocketPath, DEFAULT_HOST_IDLE_EXIT_MS, findInternalSupervisorArgs, HOST_COLD_START_ENV, @@ -472,6 +473,42 @@ describe("resolveHostChildLaunch", () => { }); }); +// Regression coverage for https://github.com/code-yeongyu/senpi/issues/1370 +describe("createInternalSocketPath", () => { + const created: string[] = []; + + afterEach(() => { + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it("creates the win32 internal directory when rpc-host-daemon does not exist yet", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "senpi-hlc-win32-")); + created.push(agentDir); + const daemonDir = join(agentDir, "rpc-host-daemon"); + expect(existsSync(daemonDir)).toBe(false); + + const internal = await createInternalSocketPath(daemonDir, "win32"); + + const dir = internal.dir; + if (dir === undefined) throw new Error("expected an internal socket directory"); + expect(existsSync(dir)).toBe(true); + expect(dirname(dir)).toBe(daemonDir); + expect(internal.socket.startsWith("\\\\.\\pipe\\")).toBe(true); + expect(internal.secretPath).toBe(join(dir, "secret")); + }); + + it("keeps the posix internal directory in the OS temp dir", async () => { + const internal = await createInternalSocketPath(join(tmpdir(), "senpi-hlc-unused"), "linux"); + + const dir = internal.dir; + if (dir === undefined) throw new Error("expected an internal socket directory"); + created.push(dir); + expect(existsSync(dir)).toBe(true); + expect(dirname(dir)).toBe(tmpdir()); + expect(internal.socket).toBe(join(dir, "host.sock")); + }); +}); + function scratch(label: string): Scratch { // Unix socket paths must stay under the platform sun_path limit (104 bytes on // macOS), so the scratch prefix and labels are kept deliberately short. From f9cf4dd3579e31b6cbba2e7bbe4f576054cf0462 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 7 Sep 2026 03:18:27 +0900 Subject: [PATCH 2/2] test(coding-agent): move the #1370 regression into suite/regressions test/AGENTS.md scopes issue regressions to suite/regressions/-.test.ts and says the legacy flat test/*.test.ts cluster must not grow, so the coverage moves out of rpc-host-lifecycle.test.ts, which is restored to its upstream content. src/modes/rpc/AGENTS.md also requires a behavior change to update changes.md and docs/rpc.md in the same increment, so both now record the recursive win32 internal socket directory and note that the public socket secret stays caller-provisioned. Reported by Codex review on #1420. --- packages/coding-agent/docs/rpc.md | 2 + .../coding-agent/src/modes/rpc/changes.md | 20 +++++++++ .../test/rpc-host-lifecycle.test.ts | 37 ----------------- .../1370-rpc-internal-socket-mkdir.test.ts | 41 +++++++++++++++++++ 4 files changed, 63 insertions(+), 37 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/1370-rpc-internal-socket-mkdir.test.ts diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 3d089e9efd..833e0e9f56 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -147,6 +147,8 @@ The command response reports only `{ cancelled }`, so this event is the only pus The lifecycle supervisor is also available to bundled/rebranded runtimes through the hidden internal launch route `--internal-rpc-host-supervisor`. This route is wire-invisible and intended only for desktop launchers: it receives the public socket, ownership directory, and the runtime command/arguments to wrap, then runs the same `host-lifecycle.ts` implementation used by `ensureHost()`. Normal CLI modes do not use or advertise this route. Compiled standalone binaries also re-enter themselves through this route automatically: a bun executable always boots its embedded entrypoint, so the script-path re-entry used under a JS runtime would be parsed as CLI arguments (`Unknown option: --socket`) and the host could never start. +On win32 the supervisor's internal hop lives under `/rpc-host-daemon/internal-`, and that directory is created recursively, so a launcher may take this route on a fresh profile where `rpc-host-daemon` does not exist yet. The public socket secret is not self-provisioned: on win32 the supervisor still reads `.secret`, which `ensureHost()` writes before spawning, so a direct launch must provision it the same way. + Hosts started through `ensureHost()` are wrapped by a lifecycle supervisor that owns the public socket and spawns the real RPC host on a private internal hop. The policy lives in `/rpc-host-daemon/settings.json`: diff --git a/packages/coding-agent/src/modes/rpc/changes.md b/packages/coding-agent/src/modes/rpc/changes.md index 25c149e024..b422fc0cbc 100644 --- a/packages/coding-agent/src/modes/rpc/changes.md +++ b/packages/coding-agent/src/modes/rpc/changes.md @@ -1,5 +1,25 @@ # changes +## win32 supervisor creates its internal socket directory recursively (2026-09-07) + +### What changed + +- `packages/coding-agent/src/modes/rpc/host-lifecycle.ts`: the win32 branch of `createInternalSocketPath()` now creates `/internal-` with `recursive: true` instead of `recursive: false`. The function is exported and takes an injectable `platform`, mirroring `spawnableChildLaunch(launch, platform)` in the same module, so the win32 bootstrap is coverable from any host. The posix branch is unchanged. +- `packages/coding-agent/docs/rpc.md`: the shared-host lifecycle section records that the win32 internal hop directory is created recursively, and that the public socket secret is still caller-provisioned. +- `packages/coding-agent/test/suite/regressions/1370-rpc-internal-socket-mkdir.test.ts`: regression coverage for the win32 bootstrap against a missing `rpc-host-daemon`, plus a guard that the posix branch stays rooted in the OS temp dir. + +### Why + +- `runHostSupervisor()` passes `paths.dir` (`/rpc-host-daemon`) as the base directory. `ensureHost()` creates that parent before spawning, but the hidden `--internal-rpc-host-supervisor` launch route does not, so on a fresh Windows profile the supervisor died during bootstrap with `ENOENT: no such file or directory, mkdir '\rpc-host-daemon\internal-'` (#1370). The posix branch never hit this because it roots the directory in `tmpdir()`, which always exists. + +### Why an extension could not handle it + +- The failure happens inside the supervisor's own bootstrap, before any session, runtime, or extension surface exists. + +### Expected merge conflict zones + +- LOW: the `createInternalSocketPath` signature and its win32 `mkdir` call in `host-lifecycle.ts`, and the internal launch route paragraph in `docs/rpc.md`. + ## Shared-host logical sessions are unlimited by default (2026-09-06) ### What changed diff --git a/packages/coding-agent/test/rpc-host-lifecycle.test.ts b/packages/coding-agent/test/rpc-host-lifecycle.test.ts index c8b25588c7..c7384d70c5 100644 --- a/packages/coding-agent/test/rpc-host-lifecycle.test.ts +++ b/packages/coding-agent/test/rpc-host-lifecycle.test.ts @@ -20,7 +20,6 @@ import { VERSION } from "../src/config.ts"; import { processIsLive, processMatchesPidFile, readProcessStartTime } from "../src/modes/app-server/daemon/process.ts"; import { createHostDaemonPaths, ensureHost, type HostLifecyclePolicyInput } from "../src/modes/rpc/host-ensure.ts"; import { - createInternalSocketPath, DEFAULT_HOST_IDLE_EXIT_MS, findInternalSupervisorArgs, HOST_COLD_START_ENV, @@ -473,42 +472,6 @@ describe("resolveHostChildLaunch", () => { }); }); -// Regression coverage for https://github.com/code-yeongyu/senpi/issues/1370 -describe("createInternalSocketPath", () => { - const created: string[] = []; - - afterEach(() => { - for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }); - }); - - it("creates the win32 internal directory when rpc-host-daemon does not exist yet", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "senpi-hlc-win32-")); - created.push(agentDir); - const daemonDir = join(agentDir, "rpc-host-daemon"); - expect(existsSync(daemonDir)).toBe(false); - - const internal = await createInternalSocketPath(daemonDir, "win32"); - - const dir = internal.dir; - if (dir === undefined) throw new Error("expected an internal socket directory"); - expect(existsSync(dir)).toBe(true); - expect(dirname(dir)).toBe(daemonDir); - expect(internal.socket.startsWith("\\\\.\\pipe\\")).toBe(true); - expect(internal.secretPath).toBe(join(dir, "secret")); - }); - - it("keeps the posix internal directory in the OS temp dir", async () => { - const internal = await createInternalSocketPath(join(tmpdir(), "senpi-hlc-unused"), "linux"); - - const dir = internal.dir; - if (dir === undefined) throw new Error("expected an internal socket directory"); - created.push(dir); - expect(existsSync(dir)).toBe(true); - expect(dirname(dir)).toBe(tmpdir()); - expect(internal.socket).toBe(join(dir, "host.sock")); - }); -}); - function scratch(label: string): Scratch { // Unix socket paths must stay under the platform sun_path limit (104 bytes on // macOS), so the scratch prefix and labels are kept deliberately short. diff --git a/packages/coding-agent/test/suite/regressions/1370-rpc-internal-socket-mkdir.test.ts b/packages/coding-agent/test/suite/regressions/1370-rpc-internal-socket-mkdir.test.ts new file mode 100644 index 0000000000..b73e7241c3 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1370-rpc-internal-socket-mkdir.test.ts @@ -0,0 +1,41 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createInternalSocketPath } from "../../../src/modes/rpc/host-lifecycle.ts"; + +// Regression coverage for https://github.com/code-yeongyu/senpi/issues/1370 +describe("createInternalSocketPath", () => { + const created: string[] = []; + + afterEach(() => { + for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it("creates the win32 internal directory when rpc-host-daemon does not exist yet", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "senpi-hlc-win32-")); + created.push(agentDir); + const daemonDir = join(agentDir, "rpc-host-daemon"); + expect(existsSync(daemonDir)).toBe(false); + + const internal = await createInternalSocketPath(daemonDir, "win32"); + + const dir = internal.dir; + if (dir === undefined) throw new Error("expected an internal socket directory"); + expect(existsSync(dir)).toBe(true); + expect(dirname(dir)).toBe(daemonDir); + expect(internal.socket.startsWith("\\\\.\\pipe\\")).toBe(true); + expect(internal.secretPath).toBe(join(dir, "secret")); + }); + + it("keeps the posix internal directory in the OS temp dir", async () => { + const internal = await createInternalSocketPath(join(tmpdir(), "senpi-hlc-unused"), "linux"); + + const dir = internal.dir; + if (dir === undefined) throw new Error("expected an internal socket directory"); + created.push(dir); + expect(existsSync(dir)).toBe(true); + expect(dirname(dir)).toBe(tmpdir()); + expect(internal.socket).toBe(join(dir, "host.sock")); + }); +});