diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 93eababc8..d5179a2f7 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/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 3d089e9ef..833e0e9f5 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 25c149e02..b422fc0cb 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/src/modes/rpc/host-lifecycle.ts b/packages/coding-agent/src/modes/rpc/host-lifecycle.ts index cb2b76198..9af19c5fc 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/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 000000000..b73e7241c --- /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")); + }); +});