diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c838789..b83fca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,35 @@ jobs: - name: Build run: pnpm build + verify-windows: + # Runs on every push and PR, not gated behind release: a Windows regression should fail the same gate any other regression does, rather than surfacing only after a release already shipped it. Real, unmocked coverage of the native-Windows named-pipe transport (see test/windows-integration.test.ts) — the mocked process.platform tests in the main suite prove the branch logic but cannot prove the OS actually accepts a named-pipe path from Node's net module. + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + - os: windows-11-arm + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v5 + with: + node-version-file: .tool-versions + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Test + # Plain vitest run, not the coverage/mutation gate: those already run once on Linux in verify. This job exists specifically to prove the suite, including the real Windows-only integration tests, passes under an actual Windows kernel. + run: pnpm test + release: needs: verify if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -107,12 +136,14 @@ jobs: strategy: fail-fast: false matrix: - # darwin and linux only, two architectures each: win32 is deliberately excluded. Node's net module has no real AF_UNIX support on Windows — its own docs state the local domain there is implemented with named pipes, which must live under \\.\pipe\ or \\?\pipe\, not an arbitrary filesystem path (see nodejs/node#55979, nodejs/node#35008). Claude Code's own peer protocol is filesystem-path UDS end to end (this repo's own docs/PROTOCOL.md), so CcPeer.start() cannot bind a listening socket on Windows at all — confirmed directly: a windows-latest and a windows-11-arm leg both built a working .exe, then crashed before printing anything at all when the smoke step ran it, because the UDS listen() call itself fails. Shipping a Windows binary would only hand users something that cannot ever open its own socket. build-sea.ts still accepts win32 as a target for anyone building locally on Windows themselves; only the CI/release matrix excludes it. + # Every (platform, arch) pair on a standard runner image: two darwin legs, two linux, two win32. Windows was excluded here previously (a genuine Node limitation: no filesystem-path AF_UNIX support on Windows, only named pipes — nodejs/node#55979), but CcPeer now branches to a real named pipe on native Windows (see paths.ts and win-proc-info.ts), so both legs build a working binary again. build-sea.ts qualifies each asset name by process.platform AND process.arch so shared-OS legs cannot clobber each other's release upload, and gives win32 its .exe. include: - os: macos-latest - os: macos-13 - os: ubuntu-latest - os: ubuntu-24.04-arm + - os: windows-latest + - os: windows-11-arm runs-on: ${{ matrix.os }} timeout-minutes: 20 permissions: diff --git a/README.md b/README.md index 96eeb22..ab30560 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,6 @@ The REST facade (`npx cc-peer`) serves `GET /sessions`, `POST /messages`, `POST - **Same-process constraint**: receipts and idle notices only reach the process that owns the peer's listening socket (the protocol verifies return addresses via kernel peer-pids). Do not split `CcPeer` listening and sending across processes or differently-owned workers. - **Single machine**: the local protocol is Unix-socket only. Writing to cloud sessions directly is blocked by design (device-attestation-signed events); bridged sessions reachable locally still work via their local mirror. -- **No native Windows support**: Claude Code's own peer protocol addresses sockets by filesystem path end to end, but Node's `net` module has no real AF_UNIX support on Windows — its local domain there is implemented with named pipes, which must live under `\\.\pipe\`, not an arbitrary path (see [nodejs/node#55979](https://github.com/nodejs/node/issues/55979)). `CcPeer.start()` cannot bind a listening socket on Windows as a result. The SEA release matrix builds darwin and linux only for this reason; running from source or `npx` on Windows hits the same limitation. +- **Windows uses a named pipe, not a Unix socket**: Node's `net` module has no real AF_UNIX support on Windows (its local domain there is a named pipe, under `\\.\pipe\`, not an arbitrary filesystem path — [nodejs/node#55979](https://github.com/nodejs/node/issues/55979)), and Claude Code's own docs confirm it uses exactly that on native Windows. `cc-peer` branches to a named pipe there automatically; nothing to configure. Windows also requires a valid, matching auth line on every inbound connection (macOS and Linux tolerate an absent or foreign one). The exact `procStart` string format `cc-peer` computes on Windows is its own convention (PowerShell's process start time, ISO-8601) rather than a confirmed match for a real native-Windows Claude Code session's own registry entries, which is not publicly documented. - **File transfers to Claude sessions** wait on an upstream feature flag (`tengu_send_file`) before Claude-side materialisation activates; peer-to-peer transfers work today. - Verified against Claude Code 2.1.269; treat every Claude Code upgrade as a potential protocol change. diff --git a/package.json b/package.json index 7fdb787..1c746d5 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "build": "turbo run _build", "_build": "tsdown && tsx scripts/generate-json-schema.ts", "lint": "turbo run _lint", - "_lint": "eslint . --cache", + "_lint": "eslint . --cache --fix", "typecheck": "turbo run _typecheck", "_typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/src/adapters/node/adapters-extra.test.ts b/src/adapters/node/adapters-extra.test.ts index 9bc0712..7238c8c 100644 --- a/src/adapters/node/adapters-extra.test.ts +++ b/src/adapters/node/adapters-extra.test.ts @@ -5,7 +5,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { connect } from "node:net"; -import { errnoOf, PsProcInfo } from "./ps-proc-info.js"; +import { PsProcInfo } from "./ps-proc-info.js"; +import { WinProcInfo } from "./win-proc-info.js"; +import { errnoOf } from "./cached-command.js"; import { pidFromSocketPath, sessionsDir, @@ -17,6 +19,8 @@ import { FsKeyStore } from "./fs-key-store.js"; import { FsRegistryStore } from "./fs-registry-store.js"; import { UdsTransport } from "./uds-transport.js"; import { keyFilePath } from "./paths.js"; +import { withPlatform } from "../../test/with-platform.js"; +import { testSocketPath } from "../../test/socket-path.js"; /** A pid no OS will hand out, so ps exits nonzero for it. */ const IMPOSSIBLE_PID = 999_999_999; @@ -41,7 +45,8 @@ describe("errnoOf", () => { }); }); -describe("PsProcInfo", () => { +// PsProcInfo is the POSIX-only adapter (CcPeer.create() only ever selects it when !isWindows()); it spawns a real ps and assumes POSIX signal-0/init-pid semantics, neither of which holds on a real Windows kernel. WinProcInfo is exercised separately below. +describe.skipIf(process.platform === "win32")("PsProcInfo", () => { const info = new PsProcInfo(); test("alive: own pid true, dead pid false, init pid EPERM-counts-as-live", async () => { @@ -80,6 +85,23 @@ describe("PsProcInfo", () => { }); }); +describe("WinProcInfo", () => { + const info = new WinProcInfo(); + + test("alive shares PsProcInfo's own signal-0 probe: own pid true, dead pid false", async () => { + expect(await info.alive(process.pid)).toBe(true); + expect(await info.alive(IMPOSSIBLE_PID)).toBe(false); + }); + + // Windows' own CreateProcess search order finds powershell.exe via the system directories even with PATH cleared, so there is no reliable way to simulate "missing binary" on a real Windows runner; skipped there rather than asserting something no longer true. Every POSIX runner this suite otherwise runs on genuinely has no powershell.exe on PATH, exercising the real ENOENT path. Successful parsing of real PowerShell output is validated by the Windows CI job's own end-to-end run of this class against a real powershell.exe. + test.skipIf(process.platform === "win32")( + "lstart resolves to undefined when powershell.exe is not on this machine", + async () => { + expect(await info.lstart(process.pid)).toBeUndefined(); + }, + ); +}); + describe("paths", () => { test("XDG_RUNTIME_DIR adds a candidate and is absent by default order", () => { const had = process.env.XDG_RUNTIME_DIR; @@ -101,10 +123,44 @@ describe("paths", () => { expect(pidFromSocketPath("/tmp/cc-socks/foo.sock")).toBe(0); }); - test("socketPathForPid honours an explicit socketDir", () => { - expect(socketPathForPid(4242, { socketDir: "/custom" })).toBe( - "/custom/4242.sock", - ); + test("pidFromSocketPath parses a named-pipe path by its own shape, regardless of the current platform", () => { + expect(pidFromSocketPath("\\\\.\\pipe\\cc-peer-4242")).toBe(4242); + expect(pidFromSocketPath("\\\\.\\pipe\\cc-peer-not-a-pid")).toBe(0); + }); + + test("socketPathForPid honours an explicit socketDir on POSIX", async () => { + await withPlatform("darwin", () => { + expect(socketPathForPid(4242, { socketDir: "/custom" })).toBe( + "/custom/4242.sock", + ); + }); + }); + + test("socketPathForPid falls back to the default candidate directory on POSIX with no config", async () => { + await withPlatform("darwin", () => { + expect(socketPathForPid(4242)).toBe( + `${socketDirCandidates()[0]}/4242.sock`, + ); + }); + }); + + test("socketPathForPid produces a named-pipe path on Windows", async () => { + await withPlatform("win32", () => { + expect(socketPathForPid(4242)).toBe("\\\\.\\pipe\\cc-peer-4242"); + }); + }); + + test("socketPathForPid namespaces the pipe name by socketDir on Windows, since a pipe has no directory of its own to keep callers apart", async () => { + await withPlatform("win32", () => { + const a = socketPathForPid(4242, { socketDir: "/tmp/cc-peer-a" }); + const b = socketPathForPid(4242, { socketDir: "/tmp/cc-peer-b" }); + expect(a).not.toBe(b); + expect(a).not.toBe("\\\\.\\pipe\\cc-peer-4242"); + expect(a.startsWith("\\\\.\\pipe\\cc-peer-")).toBe(true); + expect(a.endsWith("-4242")).toBe(true); + expect(pidFromSocketPath(a)).toBe(4242); + expect(socketPathForPid(4242, { socketDir: "/tmp/cc-peer-a" })).toBe(a); + }); }); test("sessionsDir falls back to the real home without config", () => { @@ -193,7 +249,7 @@ describe("FsRegistryStore", () => { describe("UdsTransport connection lifecycle", () => { test("readLines yields written lines and ends when the client disconnects", async () => { const home = await tempHome(); - const sockPath = join(home, "lifecycle.sock"); + const sockPath = testSocketPath(home, "lifecycle"); const transport = new UdsTransport(); let received: string[] = []; let iterationEnded = false; @@ -224,7 +280,7 @@ describe("UdsTransport connection lifecycle", () => { test("closing the listener destroys an accepted open connection", async () => { const home = await tempHome(); - const sockPath = join(home, "accepted.sock"); + const sockPath = testSocketPath(home, "accepted"); const transport = new UdsTransport(); const listener = await transport.listen(sockPath, () => { void 0; @@ -246,7 +302,7 @@ describe("UdsTransport connection lifecycle", () => { test("InboundConnection.close destroys the socket and ends iteration", async () => { const home = await tempHome(); - const sockPath = join(home, "closed-conn.sock"); + const sockPath = testSocketPath(home, "closed-conn"); const transport = new UdsTransport(); const lines: string[] = []; let ended = false; @@ -299,7 +355,8 @@ describe("spawned child liveness", () => { }); }); -describe("key file permissions", () => { +// NTFS has no POSIX permission-bit model: chmod(0o000) there doesn't remove owner read access, so the file the code under test opens stays readable and the "unreadable" branch this test means to exercise never triggers. +describe.skipIf(process.platform === "win32")("key file permissions", () => { test("an unreadable staged key surfaces as undefined rather than throwing", async () => { const home = await tempHome(); const store = new FsKeyStore({ homeDir: home }); diff --git a/src/adapters/node/adapters.test.ts b/src/adapters/node/adapters.test.ts index 880f4ce..91749b3 100644 --- a/src/adapters/node/adapters.test.ts +++ b/src/adapters/node/adapters.test.ts @@ -11,6 +11,7 @@ import { pidFromSocketPath, socketDirCandidates, } from "./paths.js"; +import { testSocketPath } from "../../test/socket-path.js"; async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "cc-peer-test-")); @@ -19,7 +20,7 @@ async function tempHome(): Promise { describe("UdsTransport", () => { test("connectWrite delivers lines to a listening socket", async () => { const home = await tempHome(); - const sockPath = join(home, "echo.sock"); + const sockPath = testSocketPath(home, "echo"); const received: string[] = []; const server = createServer((socket) => { let buffer = ""; @@ -58,7 +59,7 @@ describe("UdsTransport", () => { test("probe reports a live socket true and a dead path false", async () => { const home = await tempHome(); - const live = join(home, "live.sock"); + const live = testSocketPath(home, "live"); const server = createServer(); await new Promise((resolve) => { server.listen(live, () => { @@ -67,7 +68,7 @@ describe("UdsTransport", () => { }); const transport = new UdsTransport(); expect(await transport.probe(live)).toBe(true); - expect(await transport.probe(join(home, "missing.sock"))).toBe(false); + expect(await transport.probe(testSocketPath(home, "missing"))).toBe(false); server.close(); }); }); diff --git a/src/adapters/node/cached-command.ts b/src/adapters/node/cached-command.ts new file mode 100644 index 0000000..0987642 --- /dev/null +++ b/src/adapters/node/cached-command.ts @@ -0,0 +1,75 @@ +import { spawn } from "node:child_process"; + +/** + * The errno code of an unknown throwable: Node's process.kill throws a SystemError carrying a string code, but a defensive caller may hand us anything, so the narrowing is explicit rather than assumed. Exported for direct unit coverage of every narrowing side. + */ +export function errnoOf(error: unknown): string { + if ( + error instanceof Error && + "code" in error && + typeof error.code === "string" + ) { + return error.code; + } + return ""; +} + +/** Shared existence probe for PsProcInfo and WinProcInfo: no throw means the pid is live; EPERM means it exists but belongs to another user (still live, and Node emulates this check on Windows too); ESRCH (or any other code) means it is gone. */ +export async function signalZeroAlive(pid: number): Promise { + return new Promise((resolve) => { + try { + process.kill(pid, 0); + resolve(true); + } catch (error) { + resolve(errnoOf(error) === "EPERM"); + } + }); +} + +/** Shared per-pid cache and in-flight dedup for a proc-info command runner, used by both PsProcInfo and WinProcInfo. */ +export class CachedPidCommand { + private readonly cache = new Map< + number, + { at: number; value: string | undefined } + >(); + private static readonly CACHE_MS = 60_000; + private readonly inFlight = new Map>(); + + async run( + pid: number, + command: string, + args: readonly string[], + ): Promise { + const cached = this.cache.get(pid); + if ( + cached !== undefined && + Date.now() - cached.at < CachedPidCommand.CACHE_MS + ) { + return Promise.resolve(cached.value); + } + const existing = this.inFlight.get(pid); + if (existing !== undefined) return existing; + const promise = new Promise((resolve) => { + const child = spawn(command, args, { + env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, + stdio: ["ignore", "pipe", "ignore"], + }); + let out = ""; + child.stdout.on("data", (chunk: Buffer) => { + out += chunk.toString("utf8"); + }); + child.on("error", () => { + resolve(undefined); + }); + child.on("close", (code) => { + const value = code === 0 && out.trim().length > 0 ? out : undefined; + this.cache.set(pid, { at: Date.now(), value }); + resolve(value); + }); + }).finally(() => { + this.inFlight.delete(pid); + }); + this.inFlight.set(pid, promise); + return promise; + } +} diff --git a/src/adapters/node/paths.ts b/src/adapters/node/paths.ts index 8b4efee..bc29fe1 100644 --- a/src/adapters/node/paths.ts +++ b/src/adapters/node/paths.ts @@ -7,10 +7,19 @@ export interface PathConfig { socketDir?: string; } +/** Named-pipe prefix Windows requires; a listen()/connect() path must live under it. */ +const WINDOWS_PIPE_PREFIX = "\\\\.\\pipe\\"; + +export function isWindows(): boolean { + return process.platform === "win32"; +} + /** * Candidate socket directories, in the order the reference client accepts * them. The tuple return type guarantees at least one candidate exists, so - * callers can index [0] without a fallback branch. + * callers can index [0] without a fallback branch. Meaningless on Windows, + * where a named pipe has no filesystem directory of its own — callers there + * use socketPathForPid directly instead of building a path from a directory. */ export function socketDirCandidates( config: Readonly = {}, @@ -30,10 +39,37 @@ export function sessionsDir(config: Readonly = {}): string { return join(config.homeDir ?? homedir(), ".claude", "sessions"); } +/** Long enough that two distinct socketDir values collide only by the same astronomically small chance any truncated hash does; short enough to keep the pipe name readable. */ +const PIPE_NAMESPACE_HEX_LENGTH = 8; + +/** + * Short, path-safe token distinguishing one caller-supplied socketDir from another in a Windows pipe name. A named pipe has no filesystem directory of its own to carry that distinction the way a POSIX socket path does, so the directory is folded into the name instead. + */ +function pipeNamespace(socketDir: string): string { + return createHash("sha256") + .update(socketDir) + .digest("hex") + .slice(0, PIPE_NAMESPACE_HEX_LENGTH); +} + +/** + * On native Windows, Claude Code's own inbox is a named pipe rather than a Unix domain socket — Node's net module has no filesystem-path AF_UNIX support there at all (see docs/PROTOCOL.md), so unlike every other config override in this module, an explicit socketDir can never become a literal filesystem path on Windows: net.Server.listen() would reject it with EACCES regardless of what directory it names. Node still dispatches to the right OS primitive from the path's own shape, so UdsTransport needs no change; only path construction does. A caller-supplied socketDir keeps its usual purpose — separating one caller's sockets from another's, e.g. across concurrent test runs — by namespacing the pipe name instead of pointing at a real directory. The exact pipe name only needs to be unique per pid (and per socketDir) on this machine, not to match any specific value a real Windows Claude Code session uses. + */ export function socketPathForPid( pid: number, config: Readonly = {}, ): string { + if (isWindows()) { + const namespace = + config.socketDir === undefined + ? "" + : `-${pipeNamespace(config.socketDir)}`; + return `${WINDOWS_PIPE_PREFIX}cc-peer${namespace}-${pid.toString()}`; + } + // An explicit socketDir always wins on POSIX, matching socketDirCandidates' own precedence rule: it is a full override of automatic path construction, not merely a candidate to prefer. + if (config.socketDir !== undefined) { + return `${config.socketDir}/${pid.toString()}.sock`; + } return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`; } @@ -57,8 +93,13 @@ export function keyFilePath( } export function pidFromSocketPath(socketPath: string): number { - // substring after the final slash: split().at(-1) would need an - // unreachable empty-array fallback. + // A named-pipe path is detected by its own shape, not the current platform: parsing should work on whichever kind of path it is actually given, not on where the parsing code itself happens to run. The pid is always the final `-`-delimited segment, whether or not a socketDir namespace segment precedes it (see pipeNamespace above). + if (socketPath.startsWith(WINDOWS_PIPE_PREFIX)) { + const name = socketPath.slice(WINDOWS_PIPE_PREFIX.length); + const pid = Number.parseInt(name.slice(name.lastIndexOf("-") + 1), 10); + return Number.isNaN(pid) ? 0 : pid; + } + // substring after the final slash: split().at(-1) would need an unreachable empty-array fallback. const base = socketPath.substring(socketPath.lastIndexOf("/") + 1); const pid = Number.parseInt(base.replace(/\.sock$/, ""), 10); return Number.isNaN(pid) ? 0 : pid; diff --git a/src/adapters/node/ps-proc-info.ts b/src/adapters/node/ps-proc-info.ts index 9a66e8b..61544ff 100644 --- a/src/adapters/node/ps-proc-info.ts +++ b/src/adapters/node/ps-proc-info.ts @@ -1,76 +1,23 @@ -import { spawn } from "node:child_process"; - import type { ProcInfo } from "../../ports/proc-info.js"; - -/** - * The errno code of an unknown throwable: Node's process.kill throws a SystemError carrying a string code, but a defensive caller may hand us anything, so the narrowing is explicit rather than assumed. Exported for direct unit coverage of every narrowing side. - */ -export function errnoOf(error: unknown): string { - if ( - error instanceof Error && - "code" in error && - typeof error.code === "string" - ) { - return error.code; - } - return ""; -} +import { CachedPidCommand, signalZeroAlive } from "./cached-command.js"; export class PsProcInfo implements ProcInfo { + private readonly command = new CachedPidCommand(); + async alive(pid: number): Promise { - // Signal 0 is an existence probe: no throw means the pid is live; EPERM means it exists but belongs to another user (still live); ESRCH is gone. - return new Promise((resolve) => { - try { - process.kill(pid, 0); - resolve(true); - } catch (error) { - resolve(errnoOf(error) === "EPERM"); - } - }); + return signalZeroAlive(pid); } /** * `ps -o lstart=` under forced C locale and UTC, returning the trimmed output byte-exact. The registry's liveness check string-compares this value, so the forced environment is load-bearing: bare `ps` follows the user's locale (day-before-month order under en_GB) and local time. */ async lstart(pid: number): Promise { - const stdout = await this.runPs(pid); + const stdout = await this.command.run(pid, "ps", [ + "-o", + "lstart=", + "-p", + pid.toString(), + ]); return stdout?.trim(); } - - private readonly psCache = new Map< - number, - { at: number; value: string | undefined } - >(); - private static readonly CACHE_MS = 60_000; - private readonly inFlight = new Map>(); - - private async runPs(pid: number): Promise { - const cached = this.psCache.get(pid); - if (cached !== undefined && Date.now() - cached.at < PsProcInfo.CACHE_MS) { - return Promise.resolve(cached.value); - } - const existing = this.inFlight.get(pid); - if (existing !== undefined) return existing; - const promise = new Promise((resolve) => { - const child = spawn("ps", ["-o", "lstart=", "-p", String(pid)], { - env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, - stdio: ["ignore", "pipe", "ignore"], - }); - let out = ""; - child.stdout.on("data", (chunk: Buffer) => { - out += chunk.toString("utf8"); - }); - child.on("error", () => { - resolve(undefined); - }); - child.on("close", (code) => { - this.psCache.set(pid, { at: Date.now(), value: out }); - resolve(code === 0 && out.trim().length > 0 ? out : undefined); - }); - }).finally(() => { - this.inFlight.delete(pid); - }); - this.inFlight.set(pid, promise); - return promise; - } } diff --git a/src/adapters/node/win-proc-info.ts b/src/adapters/node/win-proc-info.ts new file mode 100644 index 0000000..c35a5b2 --- /dev/null +++ b/src/adapters/node/win-proc-info.ts @@ -0,0 +1,24 @@ +import type { ProcInfo } from "../../ports/proc-info.js"; +import { CachedPidCommand, signalZeroAlive } from "./cached-command.js"; + +/** + * Native Windows has no `ps`, so process-start-time verification uses PowerShell's own Process object instead. The exact string format is a cc-peer convention (round-trip ISO-8601, UTC), not a reproduction of whatever format a real native-Windows Claude Code session emits for its own registry entries — that value is not publicly documented and this implementation has not been verified against a live Windows Claude Code session. It is self-consistent for cc-peer's own entries (written and re-read with the same formatting), which is what roster admission actually needs for a peer this SDK itself created. + */ +export class WinProcInfo implements ProcInfo { + private readonly command = new CachedPidCommand(); + + async alive(pid: number): Promise { + return signalZeroAlive(pid); + } + + async lstart(pid: number): Promise { + const script = `(Get-Process -Id ${pid.toString()} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`; + const stdout = await this.command.run(pid, "powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + return stdout?.trim(); + } +} diff --git a/src/cc-peer-class.test.ts b/src/cc-peer-class.test.ts index 8be06b6..15f35a9 100644 --- a/src/cc-peer-class.test.ts +++ b/src/cc-peer-class.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { mkdtemp, mkdir } from "node:fs/promises"; +import { mkdtemp, mkdir, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { connect, type Socket } from "node:net"; @@ -10,6 +10,8 @@ import { UdsTransport, SystemClock } from "./adapters/node/uds-transport.js"; import { FsKeyStore } from "./adapters/node/fs-key-store.js"; import { FsRegistryStore } from "./adapters/node/fs-registry-store.js"; import { socketPathForPid } from "./adapters/node/paths.js"; +import { testSocketPath } from "./test/socket-path.js"; +import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "./test/timeouts.js"; import { MessageTooLargeError, NoLiveInboxError, @@ -70,13 +72,13 @@ function makePeer( ); } -/** Connect a raw client that speaks the wire protocol to the peer's socket. */ -async function rawClient(peer: CcPeer): Promise { - const socketPath = socketPathForPid( - process.pid, - peerOptions(tempHomeOf(peer)), - ); - const socket = connect(socketPath); +/** + * Connect a raw client that speaks the wire protocol to the peer's socket. `socketPath` defaults to recomputing it from the peer's own options, which only matches the path the peer actually bound when the ambient platform at call time is the same one it started under — true for every caller except a test that wraps this call in withPlatform("win32", ...) to exercise a server-side isWindows() branch while the peer itself started, and is genuinely listening, on the real (POSIX) test platform. Those callers must pass the real socketPath explicitly, computed before entering the mock. + */ +async function rawClient(peer: CcPeer, socketPath?: string): Promise { + const path = + socketPath ?? socketPathForPid(process.pid, peerOptions(tempHomeOf(peer))); + const socket = connect(path); await once(socket, "connect"); return socket; } @@ -87,7 +89,66 @@ function tempHomeOf(peer: CcPeer): string { return tempHomeCache.get(peer) ?? ""; } +/** + * Runs fn with process.platform reporting the given value, always restoring the real value afterward even if fn throws. process.platform's own property descriptor is configurable, so this is a standard way to exercise a platform branch without a real machine of that platform — the isWindows()-gated branches this covers only ever read process.platform, they don't depend on the kernel actually being that OS. + */ +async function withPlatform( + value: NodeJS.Platform, + fn: () => Promise, +): Promise { + const original = process.platform; + Object.defineProperty(process, "platform", { value, configurable: true }); + try { + return await fn(); + } finally { + Object.defineProperty(process, "platform", { + value: original, + configurable: true, + }); + } +} + describe("CcPeer dependency-injected construction", () => { + test("the registry entry's pidDomain reflects the real runtime platform", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const store = new FsRegistryStore({ homeDir: home }); + const entry = await store.read(process.pid); + expect(entry?.pidDomain).toBe(process.platform); + await peer.stop(); + }); + + test("on Windows, start() does not create a socket directory (a named pipe has none of its own)", async () => { + const home = await tempHome(); + // A fake transport stands in for the real UdsTransport: under a mocked win32 platform, socketPathForPid returns a named-pipe-shaped string, which a real POSIX kernel would just as happily bind as a literal (garbage) relative filename in the working directory — that would prove nothing about mkdir being skipped and would litter the test run with a stray file. Asserting directly on socketPath's shape and on the directory's absence is what this test actually means to check. + let listenedPath: string | undefined; + const peer = new CcPeer(peerOptions(home), { + transport: { + listen: async (path) => { + listenedPath = path; + return Promise.resolve({ + socketPath: path, + close: async () => Promise.resolve(), + }); + }, + connectWrite: async () => Promise.resolve(), + probe: async () => Promise.resolve(false), + }, + registry: new FsRegistryStore({ homeDir: home }), + keys: new FsKeyStore({ homeDir: home }), + procInfo: { + alive: async () => Promise.resolve(true), + lstart: async () => Promise.resolve("Sat Sep 12 10:47:31 2026"), + }, + clock: new SystemClock(), + }); + await withPlatform("win32", async () => peer.start()); + expect(listenedPath?.startsWith("\\\\.\\pipe\\")).toBe(true); + await expect(stat(join(home, "socks"))).rejects.toThrow(); + await peer.stop(); + }); + test("start throws NotStartedError when procStart is unreadable", async () => { const home = await tempHome(); const peer = makePeer(home, { @@ -184,12 +245,27 @@ describe("CcPeer dependency-injected construction", () => { await peer.stop(); }); - test("create without a logger starts cleanly (sink log path)", async () => { - const home = await tempHome(); - const peer = await CcPeer.create(peerOptions(home)); - expect(await peer.roster()).toEqual([]); - await peer.stop(); - }); + test( + "create without a logger starts cleanly (sink log path)", + async () => { + const home = await tempHome(); + const peer = await CcPeer.create(peerOptions(home)); + expect(await peer.roster()).toEqual([]); + await peer.stop(); + }, + REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS, + ); + + // This distinguishes the two branches by their genuinely different behaviour rather than by inspecting private state: ps exists on this runner and would succeed if PsProcInfo were selected instead, so this rejection only happens when WinProcInfo (backed by a real powershell.exe this machine does not have) is the one actually chosen. Skipped on real Windows: there, WinProcInfo's own powershell.exe genuinely exists and create() is expected to succeed, which is exactly what the real Windows integration test (test/windows-integration.test.ts) verifies instead. + test.skipIf(process.platform === "win32")( + "on Windows, create() selects WinProcInfo, whose PowerShell probe fails on a non-Windows test runner", + async () => { + const home = await tempHome(); + await expect( + withPlatform("win32", async () => CcPeer.create(peerOptions(home))), + ).rejects.toThrow(NotStartedError); + }, + ); test("start logs unnamed when no name is given", async () => { const home = await tempHome(); @@ -226,7 +302,7 @@ describe("CcPeer send error paths", () => { await peer.start(); const frames: string[] = []; const transport = new UdsTransport(); - const targetPath = join(home, "idle-target.sock"); + const targetPath = testSocketPath(home, "idle-target"); const listener = await transport.listen(targetPath, (conn) => { void (async () => { for await (const line of conn.readLines()) { @@ -258,7 +334,7 @@ describe("CcPeer send error paths", () => { const home = await tempHome(); const peer = makePeer(home); await peer.start(); - const keylessPath = join(home, "keyless.sock"); + const keylessPath = testSocketPath(home, "keyless"); const transport = new UdsTransport(); const listener = await transport.listen(keylessPath, () => { void 0; @@ -301,7 +377,7 @@ const targetsToClose: { close: () => Promise }[] = []; async function keyedTarget(home: string): Promise { const transport = new UdsTransport(); const keys = new FsKeyStore({ homeDir: home }); - const targetPath = join(home, "target.sock"); + const targetPath = testSocketPath(home, "target"); const listener = await transport.listen(targetPath, () => { void 0; }); @@ -440,7 +516,54 @@ describe("CcPeer send happy paths by pid and address", () => { }); describe("CcPeer inbound handling", () => { - test("a foreign auth token logs a mismatch but frames still process", async () => { + // Foreign-token tolerance is POSIX-only by design (see the Windows-specific tests below, which cover the opposite, required-auth behaviour there); skipped on real Windows rather than asserting a POSIX-only guarantee the protocol never makes there. + test.skipIf(process.platform === "win32")( + "a foreign auth token logs a mismatch but frames still process", + async () => { + const home = await tempHome(); + const logs: string[] = []; + const peer = makePeer(home, { + logger: (m) => { + logs.push(m); + }, + }); + await peer.start(); + tempHomeCache.set(peer, home); + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + const socket = await rawClient(peer); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + const envelope = + '\nhi\n'; + socket.write( + '{"msgV":1,"msg_id":"' + + newMsgId() + + '","type":"user","message":{"role":"user","content":' + + JSON.stringify(envelope) + + '},"priority":"next","from":"uds:/tmp/cc-socks/9.sock"}\n', + ); + await waitFor(() => messages.length > 0); + expect(logs.some((m) => m.includes("foreign token tolerated"))).toBe( + true, + ); + const message = messages[0] as { + hopChain?: string[]; + fromName?: string; + from?: string; + }; + expect(message.hopChain).toEqual(["1".repeat(24)]); + expect(message.fromName).toBe("hopper"); + expect(message.from).toBe("uds:/tmp/cc-socks/9.sock"); + socket.destroy(); + await peer.stop(); + }, + ); + + test("on Windows, a missing or mismatched auth line closes the connection without delivering anything", async () => { const home = await tempHome(); const logs: string[] = []; const peer = makePeer(home, { @@ -450,34 +573,65 @@ describe("CcPeer inbound handling", () => { }); await peer.start(); tempHomeCache.set(peer, home); + // Computed under the real (POSIX) test platform, before the mock below: this is the path the peer actually bound, which withPlatform("win32", ...) below must not be allowed to recompute out from under it. + const socketPath = socketPathForPid(process.pid, { + socketDir: join(home, "socks"), + }); const messages: unknown[] = []; peer.on("message", (m) => { messages.push(m); }); - const socket = await rawClient(peer); - socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); - const envelope = - '\nhi\n'; - socket.write( - '{"msgV":1,"msg_id":"' + - newMsgId() + - '","type":"user","message":{"role":"user","content":' + - JSON.stringify(envelope) + - '},"priority":"next","from":"uds:/tmp/cc-socks/9.sock"}\n', - ); - await waitFor(() => messages.length > 0); - expect(logs.some((m) => m.includes("foreign token tolerated"))).toBe(true); - const message = messages[0] as { - hopChain?: string[]; - fromName?: string; - from?: string; - }; - expect(message.hopChain).toEqual(["1".repeat(24)]); - expect(message.fromName).toBe("hopper"); - expect(message.from).toBe("uds:/tmp/cc-socks/9.sock"); - socket.destroy(); + await withPlatform("win32", async () => { + const socket = await rawClient(peer, socketPath); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + const envelope = + '\nhi\n'; + socket.write( + '{"msgV":1,"msg_id":"' + + newMsgId() + + '","type":"user","message":{"role":"user","content":' + + JSON.stringify(envelope) + + '},"priority":"next","from":"uds:/tmp/cc-socks/9.sock"}\n', + ); + await once(socket, "close"); + }); + expect( + logs.some((m) => m.includes("auth line missing or mismatched")), + ).toBe(true); + expect(messages).toHaveLength(0); + await peer.stop(); + }); + + test("on Windows, a valid matching auth line still delivers the frame", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const keyStore = new FsKeyStore({ homeDir: home }); + const socketPath = socketPathForPid(process.pid, { + socketDir: join(home, "socks"), + }); + const token = (await keyStore.readForSocket(socketPath))?.peerToken ?? ""; + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + await withPlatform("win32", async () => { + const socket = await rawClient(peer, socketPath); + socket.write('{"type":"auth","token":"' + token + '"}\n'); + const envelope = + '\nhi\n'; + socket.write( + '{"msgV":1,"msg_id":"' + + newMsgId() + + '","type":"user","message":{"role":"user","content":' + + JSON.stringify(envelope) + + '},"priority":"next","from":"uds:/tmp/cc-socks/9.sock"}\n', + ); + await waitFor(() => messages.length > 0); + socket.destroy(); + }); + expect(messages).toHaveLength(1); await peer.stop(); }); @@ -513,31 +667,35 @@ describe("CcPeer inbound handling", () => { await peer.stop(); }); - test("receipt and idle notice control frames emit their events", async () => { - const home = await tempHome(); - const peer = makePeer(home); - await peer.start(); - tempHomeCache.set(peer, home); - const receipts: unknown[] = []; - const idles: unknown[] = []; - peer.on("receipt", (r) => { - receipts.push(r); - }); - peer.on("idle", (n) => { - idles.push(n); - }); - const socket = await rawClient(peer); - socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); - socket.write( - '{"type":"control","action":"peer_message_status","status":"held","reason":"r","from":"uds:/tmp/cc-socks/9.sock","orig_msg_id":"m1","msgV":1,"msg_id":"m2"}\n', - ); - socket.write( - '{"type":"control","action":"peer_idle_notice","orig_msg_id":"m1","state":"idle","finished_at":1,"from":"uds:/tmp/cc-socks/9.sock","msgV":1,"msg_id":"m3"}\n', - ); - await waitFor(() => receipts.length > 0 && idles.length > 0); - socket.destroy(); - await peer.stop(); - }); + // Relies on the same POSIX-only foreign-token tolerance as the test above; a matching-token equivalent isn't needed since auth vetting is orthogonal to which control action a frame carries. + test.skipIf(process.platform === "win32")( + "receipt and idle notice control frames emit their events", + async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const receipts: unknown[] = []; + const idles: unknown[] = []; + peer.on("receipt", (r) => { + receipts.push(r); + }); + peer.on("idle", (n) => { + idles.push(n); + }); + const socket = await rawClient(peer); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + socket.write( + '{"type":"control","action":"peer_message_status","status":"held","reason":"r","from":"uds:/tmp/cc-socks/9.sock","orig_msg_id":"m1","msgV":1,"msg_id":"m2"}\n', + ); + socket.write( + '{"type":"control","action":"peer_idle_notice","orig_msg_id":"m1","state":"idle","finished_at":1,"from":"uds:/tmp/cc-socks/9.sock","msgV":1,"msg_id":"m3"}\n', + ); + await waitFor(() => receipts.length > 0 && idles.length > 0); + socket.destroy(); + await peer.stop(); + }, + ); test("an unknown control action emits nothing", async () => { const home = await tempHome(); diff --git a/src/cc-peer.ts b/src/cc-peer.ts index 17a54a4..fd9c332 100644 --- a/src/cc-peer.ts +++ b/src/cc-peer.ts @@ -5,9 +5,14 @@ import { UdsTransport, SystemClock } from "./adapters/node/uds-transport.js"; import { FsKeyStore } from "./adapters/node/fs-key-store.js"; import { FsRegistryStore } from "./adapters/node/fs-registry-store.js"; import { PsProcInfo } from "./adapters/node/ps-proc-info.js"; +import { WinProcInfo } from "./adapters/node/win-proc-info.js"; import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; -import { socketPathForPid, type PathConfig } from "./adapters/node/paths.js"; +import { + isWindows, + socketPathForPid, + type PathConfig, +} from "./adapters/node/paths.js"; import { buildEnvelope, parseEnvelope } from "./domain/envelope.js"; import { Pacer } from "./domain/pacer.js"; import { newMsgId } from "./domain/ids.js"; @@ -112,7 +117,7 @@ export class CcPeer extends EventEmitter { transport: new UdsTransport(), registry: new FsRegistryStore(options), keys: new FsKeyStore(options), - procInfo: new PsProcInfo(), + procInfo: isWindows() ? new WinProcInfo() : new PsProcInfo(), clock: new SystemClock(), }); await peer.start(); @@ -127,13 +132,16 @@ export class CcPeer extends EventEmitter { this.ownKey = { peerToken: randomBytes(PEER_TOKEN_BYTES).toString("hex"), procStart: (await this.deps.procInfo.lstart(process.pid)) ?? "", - pidDomain: "darwin", + pidDomain: process.platform, }; if (this.ownKey.procStart === "") { throw new NotStartedError("could not read own procStart via ps"); } await this.deps.keys.writeForSocket(socketPath, this.ownKey); - await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 }); + // A named pipe has no filesystem directory of its own to create: on Windows, listen() addresses the OS's pipe namespace directly. + if (!isWindows()) { + await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 }); + } const entry = this.buildRegistryEntry(this.ownKey.procStart); await this.deps.registry.write(entry); const ownToken = this.ownKey.peerToken; @@ -161,7 +169,7 @@ export class CcPeer extends EventEmitter { peerFeatures: ["notify_idle", "reply_across_default_dirs"], kind: "interactive", entrypoint: "cli", - pidDomain: "darwin", + pidDomain: process.platform, messagingSocketPath: socketPathForPid(process.pid, this.options), ...(this.options.name !== undefined ? { @@ -308,15 +316,25 @@ export class CcPeer extends EventEmitter { private async handleConnection( conn: Readonly<{ readLines: () => AsyncIterable; + close: () => void; }>, ownToken: string, ): Promise { const lines = conn.readLines(); const first = await lines[Symbol.asyncIterator]().next(); if (first.done === true) return; - // Auth line: verified against our own peerToken; absent or foreign tokens fall through to the unauthenticated peer class on macOS. const parsed: unknown = JSON.parse(first.value); - if (AuthLineSchema.is(parsed) && parsed.token !== ownToken) { + const authenticated = + AuthLineSchema.is(parsed) && parsed.token === ownToken; + if (isWindows()) { + // Native Windows requires a valid, matching auth line on every connection; Claude Code closes anything else without delivering it. + if (!authenticated) { + this.log("inbound auth line missing or mismatched (connection closed)"); + conn.close(); + return; + } + } else if (!authenticated) { + // Absent or foreign tokens fall through to the unauthenticated peer class on macOS and Linux rather than being rejected outright. this.log("inbound auth token mismatch (foreign token tolerated)"); } for await (const line of lines) { diff --git a/src/domain/file-transfer-extra.test.ts b/src/domain/file-transfer-extra.test.ts index f6b3d06..1df3e00 100644 --- a/src/domain/file-transfer-extra.test.ts +++ b/src/domain/file-transfer-extra.test.ts @@ -24,6 +24,7 @@ import { } from "./file-transfer.js"; import { MAX_ATTACHMENTS_PER_MESSAGE } from "../schemas/limits.js"; import type { FileAttachment } from "../schemas/wire.js"; +import { HEAVY_FS_IO_TEST_TIMEOUT_MS } from "../test/timeouts.js"; async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "cc-peer-ft2-")); @@ -74,7 +75,7 @@ describe("stageFile", () => { expect(descriptor.path).toContain("h_llo.txt"); }); - test("staged file names use 8-character sha256 and uuid prefixes, written owner-only", async () => { + test("staged file names use 8-character sha256 and uuid prefixes", async () => { const home = await tempHome(); const source = join(home, "sized.txt"); await writeFile(source, "prefix check content", "utf8"); @@ -82,9 +83,20 @@ describe("stageFile", () => { expect(basename(descriptor.path)).toMatch( /^[0-9a-f]{8}-[0-9a-f]{8}-sized\.txt$/, ); - const info = await stat(descriptor.path); - expect((info.mode & 0o777).toString(8)).toBe("600"); }); + + // NTFS has no POSIX permission-bit model: writeFile's mode option only ever toggles the read-only attribute there, so a real owner-only 0600 is a POSIX-only guarantee to begin with, not something Claude Code's own protocol depends on cross-platform. + test.skipIf(process.platform === "win32")( + "staged files are written owner-only", + async () => { + const home = await tempHome(); + const source = join(home, "sized2.txt"); + await writeFile(source, "prefix check content", "utf8"); + const descriptor = await stageFile(home, source); + const info = await stat(descriptor.path); + expect((info.mode & 0o777).toString(8)).toBe("600"); + }, + ); }); describe("materialiseAttachment refusals", () => { @@ -122,20 +134,24 @@ describe("materialiseAttachment refusals", () => { expect(result).toContain("not a regular file"); }); - test("an unreadable staged file reports expiry", async () => { - const home = await tempHome(); - const source = join(home, "locked.txt"); - await writeFile(source, "secret", "utf8"); - const descriptor = await stageFile(home, source); - await chmod(descriptor.path, 0o000); - try { - const result = await materialiseAttachment(home, "s", descriptor); - expect(result).toContain("may have expired"); - } finally { - await chmod(descriptor.path, 0o600); - await rm(descriptor.path, { force: true }); - } - }); + // NTFS has no POSIX permission-bit model: chmod(0o000) there only ever clears the read-only attribute's own opposite bit and never removes owner read access, so the file the code under test opens stays readable and the expiry branch this test means to exercise is unreachable. + test.skipIf(process.platform === "win32")( + "an unreadable staged file reports expiry", + async () => { + const home = await tempHome(); + const source = join(home, "locked.txt"); + await writeFile(source, "secret", "utf8"); + const descriptor = await stageFile(home, source); + await chmod(descriptor.path, 0o000); + try { + const result = await materialiseAttachment(home, "s", descriptor); + expect(result).toContain("may have expired"); + } finally { + await chmod(descriptor.path, 0o600); + await rm(descriptor.path, { force: true }); + } + }, + ); test("a size mismatch alone fails verification even when the hash is correct for the real bytes", async () => { const home = await tempHome(); @@ -272,23 +288,27 @@ describe("sweepSpool", () => { expect((await stat(staged)).isFile()).toBe(true); }); - test("a pass never removes more than the sweep batch limit", async () => { - const home = await tempHome(); - const dir = spoolDir(home); - await mkdir(dir, { recursive: true }); - const now = Date.now(); - const old = new Date(now - 2 * DAY_MS); - const SWEEP_BATCH = 200; - const total = SWEEP_BATCH + 1; - for (let i = 0; i < total; i += 1) { - const path = join(dir, `batch-${i.toString().padStart(4, "0")}.txt`); - await writeFile(path, "x", "utf8"); - await utimes(path, old, old); - } - - await sweepSpool(home, now); - - const remaining = await readdir(dir); - expect(remaining).toHaveLength(1); - }); + test( + "a pass never removes more than the sweep batch limit", + async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + const old = new Date(now - 2 * DAY_MS); + const SWEEP_BATCH = 200; + const total = SWEEP_BATCH + 1; + for (let i = 0; i < total; i += 1) { + const path = join(dir, `batch-${i.toString().padStart(4, "0")}.txt`); + await writeFile(path, "x", "utf8"); + await utimes(path, old, old); + } + + await sweepSpool(home, now); + + const remaining = await readdir(dir); + expect(remaining).toHaveLength(1); + }, + HEAVY_FS_IO_TEST_TIMEOUT_MS, + ); }); diff --git a/src/domain/file-transfer.ts b/src/domain/file-transfer.ts index 29f92a9..fd64c0f 100644 --- a/src/domain/file-transfer.ts +++ b/src/domain/file-transfer.ts @@ -8,7 +8,7 @@ import { unlink, writeFile, } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { MAX_ATTACHMENTS_PER_MESSAGE } from "../schemas/limits.js"; import type { FileAttachment } from "../schemas/wire.js"; @@ -77,7 +77,8 @@ export async function materialiseAttachment( ): Promise { const failure = (reason: string): string => `[SendFile: "${attachment.file_name}" was not delivered — ${reason}]`; - if (!attachment.path.startsWith("/")) return failure("invalid transfer path"); + // isAbsolute rather than a literal "/" prefix: the reference receiver's own guarantee is "an absolute path", and on Windows a staged path is drive-letter-rooted (C:\...), never "/"-rooted. + if (!isAbsolute(attachment.path)) return failure("invalid transfer path"); const absolute = resolve(attachment.path); if (dirname(absolute) !== resolve(spoolDir(homeDir))) { return failure("transfer path is outside the file-transfer spool"); diff --git a/src/test/socket-path.ts b/src/test/socket-path.ts new file mode 100644 index 0000000..30e02e5 --- /dev/null +++ b/src/test/socket-path.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +import { isWindows } from "../adapters/node/paths.js"; + +/** Matches the length socketPathForPid's own pipeNamespace uses, so a test fixture's pipe name has the same collision odds as a real one. */ +const NAMESPACE_HEX_LENGTH = 8; + +/** + * A unique, platform-appropriate socket path for test fixtures that need an arbitrary named target distinct from the pid-keyed paths socketPathForPid builds for a real peer's own listening socket (fixtures like "a second listener acting as a message target" have no pid of their own to key by). On POSIX this is a plain file under home; on Windows, home and label are folded into the pipe's own name, since a pipe has no filesystem directory of its own to keep concurrently-running fixtures apart the way a real directory does on POSIX. + */ +export function testSocketPath(home: string, label: string): string { + if (isWindows()) { + const namespace = createHash("sha256") + .update(home) + .digest("hex") + .slice(0, NAMESPACE_HEX_LENGTH); + return `\\\\.\\pipe\\cc-peer-test-${namespace}-${label}`; + } + return join(home, `${label}.sock`); +} diff --git a/src/test/timeouts.ts b/src/test/timeouts.ts new file mode 100644 index 0000000..66ad0f9 --- /dev/null +++ b/src/test/timeouts.ts @@ -0,0 +1,9 @@ +/** + * A real, non-dependency-injected CcPeer.create() spawns a genuine child process to read its own start time (ps on POSIX, powershell.exe on Windows) — unlike the DI-based fixtures elsewhere in this suite, there is no fake procInfo to short-circuit that cost. PowerShell's own startup latency varies by host; ARM Windows CI runners in particular have been observed exceeding vitest's default test timeout here, most plausibly from an x64-under-emulation PowerShell cold start. This is genuine subprocess latency to accommodate, not a correctness concern, so every test exercising a real CcPeer.create() uses this generous headroom. + */ +export const REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS = 20_000; + +/** + * A test that creates, times, stats and removes on the order of hundreds of individual files sequentially (e.g. exercising sweepSpool's own batch cap) issues far more filesystem syscalls than vitest's default test timeout was ever sized for, on any platform — NTFS under CI disk contention has been observed pushing this over 5000ms on a windows-latest runner even though the identical code path stayed well within it on the same run's ubuntu and windows-11-arm legs. Real I/O volume to accommodate, not a correctness concern. + */ +export const HEAVY_FS_IO_TEST_TIMEOUT_MS = 20_000; diff --git a/src/test/with-platform.ts b/src/test/with-platform.ts new file mode 100644 index 0000000..362d2ea --- /dev/null +++ b/src/test/with-platform.ts @@ -0,0 +1,18 @@ +/** + * Runs fn with process.platform reporting the given value, always restoring the real value afterward even if fn throws. process.platform's own property descriptor is configurable, so this is a standard way to exercise a platform branch without a real machine of that platform — the isWindows()-gated branches this covers only ever read process.platform, they don't depend on the kernel actually being that OS. + */ +export async function withPlatform( + value: NodeJS.Platform, + fn: () => T | Promise, +): Promise { + const original = process.platform; + Object.defineProperty(process, "platform", { value, configurable: true }); + try { + return await fn(); + } finally { + Object.defineProperty(process, "platform", { + value: original, + configurable: true, + }); + } +} diff --git a/test/api-extra.test.ts b/test/api-extra.test.ts index 81c9b72..39668d0 100644 --- a/test/api-extra.test.ts +++ b/test/api-extra.test.ts @@ -8,6 +8,7 @@ import type { } from "node:stream/web"; import { CcPeer } from "../src/cc-peer.js"; +import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "../src/test/timeouts.js"; import { createApiServer, hostnameOf, @@ -80,191 +81,202 @@ describe("pure helpers", () => { }); }); -describe("REST facade routes not covered by the happy-path test", () => { - test("an unknown path returns 404", async () => { - const home = await tempHome(); - const p = await peer(home, "route-peer", "socks-a"); - const server = await createApiServer(p, {}); - const res = await fetch(`http://127.0.0.1:${server.port.toString()}/nope`, { - headers: { authorization: `Bearer ${server.token ?? ""}` }, +describe( + "REST facade routes not covered by the happy-path test", + { timeout: REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS }, + () => { + test("an unknown path returns 404", async () => { + const home = await tempHome(); + const p = await peer(home, "route-peer", "socks-a"); + const server = await createApiServer(p, {}); + const res = await fetch( + `http://127.0.0.1:${server.port.toString()}/nope`, + { + headers: { authorization: `Bearer ${server.token ?? ""}` }, + }, + ); + expect(res.status).toBe(404); + await server.close(); + await p.stop(); }); - expect(res.status).toBe(404); - await server.close(); - await p.stop(); - }); - test("noToken:true serves without any authorization header", async () => { - const home = await tempHome(); - const p = await peer(home, "no-token-peer", "socks-b"); - const server = await createApiServer(p, { noToken: true }); - expect(server.token).toBeUndefined(); - const res = await fetch( - `http://127.0.0.1:${server.port.toString()}/healthz`, - ); - expect(res.status).toBe(200); - await server.close(); - await p.stop(); - }); + test("noToken:true serves without any authorization header", async () => { + const home = await tempHome(); + const p = await peer(home, "no-token-peer", "socks-b"); + const server = await createApiServer(p, { noToken: true }); + expect(server.token).toBeUndefined(); + const res = await fetch( + `http://127.0.0.1:${server.port.toString()}/healthz`, + ); + expect(res.status).toBe(200); + await server.close(); + await p.stop(); + }); - test("an explicit token option is honoured", async () => { - const home = await tempHome(); - const p = await peer(home, "fixed-token-peer", "socks-c"); - const server = await createApiServer(p, { token: "fixed-secret" }); - expect(server.token).toBe("fixed-secret"); - const res = await fetch( - `http://127.0.0.1:${server.port.toString()}/healthz`, - { - headers: { authorization: "Bearer fixed-secret" }, - }, - ); - expect(res.status).toBe(200); - await server.close(); - await p.stop(); - }); + test("an explicit token option is honoured", async () => { + const home = await tempHome(); + const p = await peer(home, "fixed-token-peer", "socks-c"); + const server = await createApiServer(p, { token: "fixed-secret" }); + expect(server.token).toBe("fixed-secret"); + const res = await fetch( + `http://127.0.0.1:${server.port.toString()}/healthz`, + { + headers: { authorization: "Bearer fixed-secret" }, + }, + ); + expect(res.status).toBe(200); + await server.close(); + await p.stop(); + }); - test("an explicit port option is honoured", async () => { - const home = await tempHome(); - const p = await peer(home, "port-peer", "socks-d"); - const first = await createApiServer(p, { noToken: true }); - await first.close(); - const server = await createApiServer(p, { - port: first.port, - noToken: true, + test("an explicit port option is honoured", async () => { + const home = await tempHome(); + const p = await peer(home, "port-peer", "socks-d"); + const first = await createApiServer(p, { noToken: true }); + await first.close(); + const server = await createApiServer(p, { + port: first.port, + noToken: true, + }); + expect(server.port).toBe(first.port); + await server.close(); + await p.stop(); }); - expect(server.port).toBe(first.port); - await server.close(); - await p.stop(); - }); - test("POST /idle-subscriptions subscribes and returns a msgId", async () => { - const home = await tempHome(); - const sender = await peer(home, "idle-sender", "socks-e1"); - const target = await peer(home, "idle-target", "socks-e2"); - const server = await createApiServer(sender, {}); - const res = await fetch( - `http://127.0.0.1:${server.port.toString()}/idle-subscriptions`, - { - method: "POST", - headers: { - authorization: `Bearer ${server.token ?? ""}`, - "content-type": "application/json", + test("POST /idle-subscriptions subscribes and returns a msgId", async () => { + const home = await tempHome(); + const sender = await peer(home, "idle-sender", "socks-e1"); + const target = await peer(home, "idle-target", "socks-e2"); + const server = await createApiServer(sender, {}); + const res = await fetch( + `http://127.0.0.1:${server.port.toString()}/idle-subscriptions`, + { + method: "POST", + headers: { + authorization: `Bearer ${server.token ?? ""}`, + "content-type": "application/json", + }, + body: JSON.stringify({ to: { name: "idle-target" } }), }, - body: JSON.stringify({ to: { name: "idle-target" } }), - }, - ); - expect(res.status).toBe(202); - const accepted = (await res.json()) as { msgId: string }; - expect(accepted.msgId).toMatch(/^[0-9a-f-]{36}$/); - await server.close(); - await sender.stop(); - await target.stop(); - }); + ); + expect(res.status).toBe(202); + const accepted = (await res.json()) as { msgId: string }; + expect(accepted.msgId).toMatch(/^[0-9a-f-]{36}$/); + await server.close(); + await sender.stop(); + await target.stop(); + }); - test("send by pid and by address succeed via REST", async () => { - const home = await tempHome(); - const sender = await peer(home, "pid-sender", "socks-f1"); - const target = await peer(home, "pid-target", "socks-f2"); - const server = await createApiServer(sender, {}); - const auth = { authorization: `Bearer ${server.token ?? ""}` }; + test("send by pid and by address succeed via REST", async () => { + const home = await tempHome(); + const sender = await peer(home, "pid-sender", "socks-f1"); + const target = await peer(home, "pid-target", "socks-f2"); + const server = await createApiServer(sender, {}); + const auth = { authorization: `Bearer ${server.token ?? ""}` }; - const roster = await sender.roster(); - const targetEntry = roster.find((e) => e.name === "pid-target"); - expect(targetEntry).toBeDefined(); + const roster = await sender.roster(); + const targetEntry = roster.find((e) => e.name === "pid-target"); + expect(targetEntry).toBeDefined(); - const byPid = await fetch( - `http://127.0.0.1:${server.port.toString()}/messages`, - { - method: "POST", - headers: { ...auth, "content-type": "application/json" }, - body: JSON.stringify({ to: { pid: targetEntry?.pid }, body: "by pid" }), - }, - ); - expect(byPid.status).toBe(202); + const byPid = await fetch( + `http://127.0.0.1:${server.port.toString()}/messages`, + { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ + to: { pid: targetEntry?.pid }, + body: "by pid", + }), + }, + ); + expect(byPid.status).toBe(202); - const byAddress = await fetch( - `http://127.0.0.1:${server.port.toString()}/messages`, - { - method: "POST", - headers: { ...auth, "content-type": "application/json" }, - body: JSON.stringify({ - to: { address: targetEntry?.messagingSocketPath }, - body: "by address", - }), - }, - ); - expect(byAddress.status).toBe(202); + const byAddress = await fetch( + `http://127.0.0.1:${server.port.toString()}/messages`, + { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ + to: { address: targetEntry?.messagingSocketPath }, + body: "by address", + }), + }, + ); + expect(byAddress.status).toBe(202); - await server.close(); - await sender.stop(); - await target.stop(); - }); + await server.close(); + await sender.stop(); + await target.stop(); + }); - test("GET /events streams message, receipt, and idle events over SSE", async () => { - const home = await tempHome(); - const p = await peer(home, "sse-peer", "socks-g"); - const server = await createApiServer(p, {}); - const controller = new AbortController(); - const response = await fetch( - `http://127.0.0.1:${server.port.toString()}/events`, - { - headers: { authorization: `Bearer ${server.token ?? ""}` }, - signal: controller.signal, - }, - ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe("text/event-stream"); - const reader: ReadableStreamDefaultReader | undefined = - response.body?.getReader(); - expect(reader).toBeDefined(); - const decoder = new TextDecoder(); - let buffered = ""; + test("GET /events streams message, receipt, and idle events over SSE", async () => { + const home = await tempHome(); + const p = await peer(home, "sse-peer", "socks-g"); + const server = await createApiServer(p, {}); + const controller = new AbortController(); + const response = await fetch( + `http://127.0.0.1:${server.port.toString()}/events`, + { + headers: { authorization: `Bearer ${server.token ?? ""}` }, + signal: controller.signal, + }, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + const reader: ReadableStreamDefaultReader | undefined = + response.body?.getReader(); + expect(reader).toBeDefined(); + const decoder = new TextDecoder(); + let buffered = ""; - async function readUntil(marker: string): Promise { - for (let i = 0; i < 50 && !buffered.includes(marker); i += 1) { - const chunk: ReadableStreamReadResult | undefined = - await reader?.read(); - if (chunk?.value !== undefined) buffered += decoder.decode(chunk.value); + async function readUntil(marker: string): Promise { + for (let i = 0; i < 50 && !buffered.includes(marker); i += 1) { + const chunk: ReadableStreamReadResult | undefined = + await reader?.read(); + if (chunk?.value !== undefined) + buffered += decoder.decode(chunk.value); + } + expect(buffered).toContain(marker); } - expect(buffered).toContain(marker); - } - await readUntil(": connected"); - p.emit("message", { body: "hi", msgId: "m1" }); - await readUntil("event: message"); - p.emit("receipt", { status: "held" }); - await readUntil("event: receipt"); - p.emit("idle", { state: "idle" }); - await readUntil("event: idle"); + await readUntil(": connected"); + p.emit("message", { body: "hi", msgId: "m1" }); + await readUntil("event: message"); + p.emit("receipt", { status: "held" }); + await readUntil("event: receipt"); + p.emit("idle", { state: "idle" }); + await readUntil("event: idle"); - controller.abort(); - await server.close(); - await p.stop(); - }, 10_000); + controller.abort(); + await server.close(); + await p.stop(); + }, 10_000); - test("POST /messages forwards a supplied priority and fromMode to send", async () => { - const home = await tempHome(); - const sender = await peer(home, "priority-sender", "socks-h1"); - const receiver = await peer(home, "priority-receiver", "socks-h2"); - const server = await createApiServer(sender, {}); - const response = await fetch( - `http://127.0.0.1:${server.port.toString()}/messages`, - { - method: "POST", - headers: { - authorization: `Bearer ${server.token ?? ""}`, - "content-type": "application/json", + test("POST /messages forwards a supplied priority and fromMode to send", async () => { + const home = await tempHome(); + const sender = await peer(home, "priority-sender", "socks-h1"); + const receiver = await peer(home, "priority-receiver", "socks-h2"); + const server = await createApiServer(sender, {}); + const response = await fetch( + `http://127.0.0.1:${server.port.toString()}/messages`, + { + method: "POST", + headers: { + authorization: `Bearer ${server.token ?? ""}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + to: { name: "priority-receiver" }, + body: "urgent", + priority: "next", + fromMode: "bypass", + }), }, - body: JSON.stringify({ - to: { name: "priority-receiver" }, - body: "urgent", - priority: "next", - fromMode: "bypass", - }), - }, - ); - expect(response.status).toBe(202); - await server.close(); - await sender.stop(); - await receiver.stop(); - }); -}); + ); + expect(response.status).toBe(202); + await server.close(); + await sender.stop(); + await receiver.stop(); + }); + }, +); diff --git a/test/api.test.ts b/test/api.test.ts index bbaa782..9624ee3 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { request } from "node:http"; import { CcPeer } from "../src/cc-peer.js"; import { createApiServer } from "../src/api/server.js"; +import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "../src/test/timeouts.js"; async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "cc-peer-api-")); @@ -22,99 +23,107 @@ function peerOptions(home: string) { } describe("REST facade", () => { - test("serves healthz, openapi, sessions, auth gate, and message send", async () => { - const home = await tempHome(); - const peer = await CcPeer.create(peerOptions(home)); - const server = await createApiServer(peer, {}); - const base = `http://127.0.0.1:${server.port.toString()}`; - const auth = { authorization: `Bearer ${server.token ?? ""}` }; + test( + "serves healthz, openapi, sessions, auth gate, and message send", + async () => { + const home = await tempHome(); + const peer = await CcPeer.create(peerOptions(home)); + const server = await createApiServer(peer, {}); + const base = `http://127.0.0.1:${server.port.toString()}`; + const auth = { authorization: `Bearer ${server.token ?? ""}` }; - const health = await fetch(`${base}/healthz`, { headers: auth }); - expect(health.status).toBe(200); - expect(await health.json()).toEqual({ ok: true }); + const health = await fetch(`${base}/healthz`, { headers: auth }); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ ok: true }); - const spec = await fetch(`${base}/openapi.json`, { headers: auth }); - expect(spec.status).toBe(200); - const document = (await spec.json()) as { - openapi: string; - components: { schemas: Record }; - }; - expect(document.openapi).toBe("3.1.0"); - expect(Object.keys(document.components.schemas)).toContain( - "SendMessageRequest", - ); + const spec = await fetch(`${base}/openapi.json`, { headers: auth }); + expect(spec.status).toBe(200); + const document = (await spec.json()) as { + openapi: string; + components: { schemas: Record }; + }; + expect(document.openapi).toBe("3.1.0"); + expect(Object.keys(document.components.schemas)).toContain( + "SendMessageRequest", + ); - const unauthorized = await fetch(`${base}/sessions`); - expect(unauthorized.status).toBe(401); + const unauthorized = await fetch(`${base}/sessions`); + expect(unauthorized.status).toBe(401); - // fetch refuses to override the forbidden Host header, so probe the - // DNS-rebinding defence with a raw request that does set it. - const rebinding = await new Promise((resolve) => { - const req = request( - `${base}/healthz`, - { headers: { Host: "evil.example.com" } }, - (res) => { - resolve(res.statusCode ?? 0); - res.resume(); - }, - ); - req.on("error", () => { - resolve(0); + // fetch refuses to override the forbidden Host header, so probe the + // DNS-rebinding defence with a raw request that does set it. + const rebinding = await new Promise((resolve) => { + const req = request( + `${base}/healthz`, + { headers: { Host: "evil.example.com" } }, + (res) => { + resolve(res.statusCode ?? 0); + res.resume(); + }, + ); + req.on("error", () => { + resolve(0); + }); + req.end(); }); - req.end(); - }); - expect(rebinding).toBe(403); + expect(rebinding).toBe(403); - const second = await CcPeer.create({ - homeDir: home, - socketDir: join(home, "socks-api-2"), - name: "api-test-target", - logger: () => { - void 0; - }, - }); - const received: string[] = []; - second.on("message", (m: Readonly<{ body: string }>) => { - received.push(m.body); - }); + const second = await CcPeer.create({ + homeDir: home, + socketDir: join(home, "socks-api-2"), + name: "api-test-target", + logger: () => { + void 0; + }, + }); + const received: string[] = []; + second.on("message", (m: Readonly<{ body: string }>) => { + received.push(m.body); + }); - const sessions = await fetch(`${base}/sessions`, { headers: auth }); - expect(sessions.status).toBe(200); - const roster = (await sessions.json()) as { sessions: { name?: string }[] }; - expect(roster.sessions.some((s) => s.name === "api-test-target")).toBe( - true, - ); + const sessions = await fetch(`${base}/sessions`, { headers: auth }); + expect(sessions.status).toBe(200); + const roster = (await sessions.json()) as { + sessions: { name?: string }[]; + }; + expect(roster.sessions.some((s) => s.name === "api-test-target")).toBe( + true, + ); - const sent = await fetch(`${base}/messages`, { - method: "POST", - headers: { ...auth, "content-type": "application/json" }, - body: JSON.stringify({ - to: { name: "api-test-target" }, - body: "hello via REST", - }), - }); - expect(sent.status).toBe(202); - const accepted = (await sent.json()) as { msgId: string }; - expect(accepted.msgId).toMatch(/^[0-9a-f-]{36}$/); + const sent = await fetch(`${base}/messages`, { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ + to: { name: "api-test-target" }, + body: "hello via REST", + }), + }); + expect(sent.status).toBe(202); + const accepted = (await sent.json()) as { msgId: string }; + expect(accepted.msgId).toMatch(/^[0-9a-f-]{36}$/); - const invalid = await fetch(`${base}/messages`, { - method: "POST", - headers: { ...auth, "content-type": "application/json" }, - body: JSON.stringify({ body: "missing target" }), - }); - expect(invalid.status).toBe(500); - expect(((await invalid.json()) as { error: string }).error).toContain("to"); + const invalid = await fetch(`${base}/messages`, { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ body: "missing target" }), + }); + expect(invalid.status).toBe(500); + expect(((await invalid.json()) as { error: string }).error).toContain( + "to", + ); - await new Promise((resolve) => { - const timer = setTimeout(() => { - resolve(); - }, 2_000); - timer.unref(); - }); - expect(received).toEqual(["hello via REST"]); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 2_000); + timer.unref(); + }); + expect(received).toEqual(["hello via REST"]); - await server.close(); - await second.stop(); - await peer.stop(); - }, 20_000); + await server.close(); + await second.stop(); + await peer.stop(); + }, + REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS, + ); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index d592815..097f756 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CcPeer } from "../src/cc-peer.js"; +import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "../src/test/timeouts.js"; async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "cc-peer-it-")); @@ -24,53 +25,65 @@ function peerOptions(home: string, name: string, suffix: string) { } describe("CcPeer integration", () => { - test("two peers discover each other and exchange a message", async () => { - const home = await tempHome(); - const alice = await CcPeer.create(peerOptions(home, "alice", "a")); - const bob = await CcPeer.create(peerOptions(home, "bob", "b")); - const received: string[] = []; - bob.on("message", (m: Readonly<{ body: string }>) => { - received.push(m.body); - }); - const rosterOnAlice = await alice.roster(); - const bobEntry = rosterOnAlice.find((e) => e.name === "bob"); - expect(bobEntry).toBeDefined(); - const { msgId } = await alice.send({ name: "bob" }, "hello from alice"); - expect(msgId).toMatch(/^[0-9a-f-]{36}$/); - await new Promise((resolve) => { - const timer = setTimeout(() => { - resolve(); - }, 2_000); - timer.unref(); - }); - expect(received).toEqual(["hello from alice"]); - await alice.stop(); - await bob.stop(); - }, 15_000); + test( + "two peers discover each other and exchange a message", + async () => { + const home = await tempHome(); + const alice = await CcPeer.create(peerOptions(home, "alice", "a")); + const bob = await CcPeer.create(peerOptions(home, "bob", "b")); + const received: string[] = []; + bob.on("message", (m: Readonly<{ body: string }>) => { + received.push(m.body); + }); + const rosterOnAlice = await alice.roster(); + const bobEntry = rosterOnAlice.find((e) => e.name === "bob"); + expect(bobEntry).toBeDefined(); + const { msgId } = await alice.send({ name: "bob" }, "hello from alice"); + expect(msgId).toMatch(/^[0-9a-f-]{36}$/); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 2_000); + timer.unref(); + }); + expect(received).toEqual(["hello from alice"]); + await alice.stop(); + await bob.stop(); + }, + REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS, + ); - test("roster excludes own entry and dead sockets", async () => { - const home = await tempHome(); - const solo = await CcPeer.create(peerOptions(home, "solo", "s")); - const roster = await solo.roster(); - expect(roster.find((e) => e.name === "solo")).toBeUndefined(); - await solo.stop(); - const afterStop = await solo.roster(); - expect(afterStop.find((e) => e.name === "solo")).toBeUndefined(); - }, 10_000); + test( + "roster excludes own entry and dead sockets", + async () => { + const home = await tempHome(); + const solo = await CcPeer.create(peerOptions(home, "solo", "s")); + const roster = await solo.roster(); + expect(roster.find((e) => e.name === "solo")).toBeUndefined(); + await solo.stop(); + const afterStop = await solo.roster(); + expect(afterStop.find((e) => e.name === "solo")).toBeUndefined(); + }, + REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS, + ); - test("stop removes registry and key artifacts", async () => { - const home = await tempHome(); - const peer = await CcPeer.create(peerOptions(home, "ephemeral", "e")); - const registry = await readFile( - join(home, ".claude", "sessions", `${process.pid.toString()}.json`), - "utf8", - ); - expect(registry).toContain("ephemeral"); - await peer.stop(); - await expect( - readFile( + test( + "stop removes registry and key artifacts", + async () => { + const home = await tempHome(); + const peer = await CcPeer.create(peerOptions(home, "ephemeral", "e")); + const registry = await readFile( join(home, ".claude", "sessions", `${process.pid.toString()}.json`), - ), - ).rejects.toThrow(); - }, 10_000); + "utf8", + ); + expect(registry).toContain("ephemeral"); + await peer.stop(); + await expect( + readFile( + join(home, ".claude", "sessions", `${process.pid.toString()}.json`), + ), + ).rejects.toThrow(); + }, + REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS, + ); }); diff --git a/test/windows-integration.test.ts b/test/windows-integration.test.ts new file mode 100644 index 0000000..60c2dc3 --- /dev/null +++ b/test/windows-integration.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "vitest"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect } from "node:net"; +import { once } from "node:events"; + +import { CcPeer } from "../src/cc-peer.js"; +import { socketPathForPid } from "../src/adapters/node/paths.js"; +import { FsKeyStore } from "../src/adapters/node/fs-key-store.js"; +import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "../src/test/timeouts.js"; + +/** + * Real, unmocked native-Windows coverage: every other Windows-specific test in this package mocks process.platform on a POSIX runner, which proves the branch logic but cannot prove the OS actually accepts a named-pipe path from Node's net module the way the unit tests assume. This file skips everywhere except a genuine win32 process, so it runs for real only on the CI job pinned to windows-latest and windows-11-arm. + */ +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-win-")); +} + +describe.skipIf(process.platform !== "win32")( + "native Windows named-pipe integration", + { timeout: REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS }, + () => { + test("a peer binds the default named-pipe path with no socketDir override", async () => { + const home = await tempHome(); + const peer = await CcPeer.create({ + homeDir: home, + name: "win-default", + logger: () => { + void 0; + }, + }); + const roster = await peer.roster(); + expect(roster).toEqual([]); + await peer.stop(); + }); + + test("a raw client without a valid auth line is closed without delivering anything", async () => { + const home = await tempHome(); + const peer = await CcPeer.create({ + homeDir: home, + name: "win-reject", + logger: () => { + void 0; + }, + }); + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + const socketPath = socketPathForPid(process.pid); + const socket = connect(socketPath); + await once(socket, "connect"); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + socket.write( + '{"msgV":1,"msg_id":"11111111-1111-4111-8111-111111111111","type":"user","message":{"role":"user","content":"\\nhi\\n"},"priority":"next","from":"uds:/x"}\n', + ); + await once(socket, "close"); + expect(messages).toHaveLength(0); + await peer.stop(); + }); + + test("a raw client with the real published auth token is delivered", async () => { + const home = await tempHome(); + const peer = await CcPeer.create({ + homeDir: home, + name: "win-accept", + logger: () => { + void 0; + }, + }); + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + const socketPath = socketPathForPid(process.pid); + const keyStore = new FsKeyStore({ homeDir: home }); + const token = (await keyStore.readForSocket(socketPath))?.peerToken ?? ""; + const socket = connect(socketPath); + await once(socket, "connect"); + socket.write('{"type":"auth","token":"' + token + '"}\n'); + socket.write( + '{"msgV":1,"msg_id":"11111111-1111-4111-8111-111111111111","type":"user","message":{"role":"user","content":"\\nhi\\n"},"priority":"next","from":"uds:/x"}\n', + ); + const deadline = Date.now() + 5_000; + while (messages.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, 50); + timer.unref(); + }); + } + expect(messages).toHaveLength(1); + socket.destroy(); + await peer.stop(); + }); + }, +);