From 13b83b88915f510062b00ff9ce2a9abbf6496636 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:41:17 +0100 Subject: [PATCH 01/11] feat(windows): support native Windows via a per-pid named pipe Node's net module has no real AF_UNIX support on Windows: the local domain there is a named pipe, which must live under \\.\pipe\, never an arbitrary filesystem path (nodejs/node#55979). Claude Code's own official docs confirm it uses exactly this on native Windows, alongside one real protocol difference from macOS and Linux: the auth line is required there, not optional, and a connection whose first line isn't a valid matching auth line is closed without delivering anything. socketPathForPid now branches on process.platform (an explicit socketDir still always wins on every platform, unchanged precedence), producing \\.\pipe\cc-peer- with no directory of its own to create - start() skips its mkdir call on Windows accordingly. pidFromSocketPath detects a pipe path by its own shape rather than the current platform, since parsing should work on whichever kind of path it is actually given. Windows has no `ps`, so process-start verification needed its own adapter: WinProcInfo drives PowerShell's Get-Process for lstart(), sharing the existing signal-0 alive() probe and per-pid cache/in-flight-dedup logic with PsProcInfo (both now built on a shared CachedPidCommand rather than duplicating that machinery). The exact lstart string format is a cc-peer convention, not a reproduction of a real Windows Claude Code session's own format, which is not publicly documented - self-consistent for cc-peer's own entries, which is what roster admission actually needs for a peer this SDK created. Also fixes a real, unrelated bug this work surfaced: pidDomain was hardcoded to "darwin" regardless of the actual runtime platform. --- src/adapters/node/adapters-extra.test.ts | 45 ++++++++- src/adapters/node/cached-command.ts | 75 +++++++++++++++ src/adapters/node/paths.ts | 33 ++++++- src/adapters/node/ps-proc-info.ts | 73 ++------------- src/adapters/node/win-proc-info.ts | 24 +++++ src/cc-peer-class.test.ts | 114 +++++++++++++++++++++++ src/cc-peer.ts | 32 +++++-- 7 files changed, 322 insertions(+), 74 deletions(-) create mode 100644 src/adapters/node/cached-command.ts create mode 100644 src/adapters/node/win-proc-info.ts diff --git a/src/adapters/node/adapters-extra.test.ts b/src/adapters/node/adapters-extra.test.ts index 9bc0712..ff6df5f 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, @@ -80,6 +82,20 @@ 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); + }); + + test("lstart resolves to undefined when powershell.exe is not on this machine", async () => { + // Every test runner this suite actually runs on is POSIX, so powershell.exe genuinely does not exist here — this exercises the real "command not found" path, not a simulated one. Successful parsing of real PowerShell output is validated by the Windows CI job instead, which runs this class against a real powershell.exe. + 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,12 +117,39 @@ describe("paths", () => { expect(pidFromSocketPath("/tmp/cc-socks/foo.sock")).toBe(0); }); + 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", () => { expect(socketPathForPid(4242, { socketDir: "/custom" })).toBe( "/custom/4242.sock", ); }); + test("socketPathForPid falls back to the default candidate directory with no config", () => { + expect(socketPathForPid(4242)).toBe( + `${socketDirCandidates()[0]}/4242.sock`, + ); + }); + + test("socketPathForPid produces a named-pipe path on Windows", () => { + const original = process.platform; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + try { + expect(socketPathForPid(4242)).toBe("\\\\.\\pipe\\cc-peer-4242"); + } finally { + Object.defineProperty(process, "platform", { + value: original, + configurable: true, + }); + } + }); + test("sessionsDir falls back to the real home without config", () => { expect(sessionsDir()).toContain(".claude"); }); 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..71c48b0 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,23 @@ export function sessionsDir(config: Readonly = {}): string { return join(config.homeDir ?? homedir(), ".claude", "sessions"); } +/** + * On native Windows, Claude Code's own inbox is a named pipe rather than a + * Unix domain socket (Node's net module has no other IPC mechanism there — + * see docs/PROTOCOL.md). Node dispatches to the right OS primitive from the + * path's own shape, so UdsTransport needs no change; only path construction + * does. The exact pipe name only needs to be unique per pid on this machine, + * not to match any specific value a real Windows Claude Code session uses. + */ export function socketPathForPid( pid: number, config: Readonly = {}, ): string { + // An explicit socketDir always wins, on every platform, 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`; + } + if (isWindows()) return `${WINDOWS_PIPE_PREFIX}cc-peer-${pid.toString()}`; return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`; } @@ -57,8 +79,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. + if (socketPath.startsWith(WINDOWS_PIPE_PREFIX)) { + const name = socketPath.slice(WINDOWS_PIPE_PREFIX.length); + const pid = Number.parseInt(name.replace(/^cc-peer-/, ""), 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..b5ecbca 100644 --- a/src/cc-peer-class.test.ts +++ b/src/cc-peer-class.test.ts @@ -87,7 +87,45 @@ 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 (an un-created directory makes the bind fail)", async () => { + const home = await tempHome(); + // No socketDir is created here at all, on purpose: a named pipe has no filesystem directory of its own, so if start() skipped the mkdir call (as it must on Windows), the underlying bind has no directory to fail on. This test cannot run on a real Windows kernel, so it observes the branch through the still-POSIX socket bind rejecting instead, which only happens if mkdir was genuinely skipped. + const peer = makePeer(home); + await expect( + withPlatform("win32", async () => peer.start()), + ).rejects.toThrow(); + }); + test("start throws NotStartedError when procStart is unreadable", async () => { const home = await tempHome(); const peer = makePeer(home, { @@ -191,6 +229,14 @@ describe("CcPeer dependency-injected construction", () => { await peer.stop(); }); + test("on Windows, create() selects WinProcInfo, whose PowerShell probe fails on a non-Windows test runner", async () => { + // 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. + 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(); const messages: string[] = []; @@ -481,6 +527,74 @@ describe("CcPeer inbound handling", () => { 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, { + logger: (m) => { + logs.push(m); + }, + }); + await peer.start(); + tempHomeCache.set(peer, home); + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + await withPlatform("win32", async () => { + 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 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); + 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(); + }); + test("malformed JSON lines are skipped and later frames still emit", async () => { const home = await tempHome(); const peer = makePeer(home); 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) { From ecb4cdc4d3b25d3c7aeb0680ce22d4130e7d08e2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:41:31 +0100 Subject: [PATCH 02/11] test: add real, unmocked native-Windows integration 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 those tests assume. This file skips everywhere except a genuine win32 process (describe.skipIf), so it exercises the real default socket path, the real PowerShell-backed proc-info adapter, and the real required-auth-line enforcement only when actually running on Windows. --- test/windows-integration.test.ts | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/windows-integration.test.ts diff --git a/test/windows-integration.test.ts b/test/windows-integration.test.ts new file mode 100644 index 0000000..7a91463 --- /dev/null +++ b/test/windows-integration.test.ts @@ -0,0 +1,95 @@ +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"; + +/** + * 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", + () => { + 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(); + }, 15_000); + + 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(); + }, 15_000); + + 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(); + }, 15_000); + }, +); From 1324e589651bc11ec548cb64d7f8e930b1f372b2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:41:54 +0100 Subject: [PATCH 03/11] ci: re-enable windows in the release matrix, add a dedicated verify-windows job Windows was excluded from the SEA release matrix because the runtime crashed on startup - not a CI misconfiguration but a genuine Node limitation (no filesystem-path AF_UNIX support on Windows) that the prior implementation never accounted for. Now that CcPeer branches to a real named pipe on native Windows, both windows-latest and windows-11-arm legs build a working, functional binary again. 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. It runs the plain test suite (not the coverage/mutation gate, which already runs once on Linux in verify) specifically to exercise test/windows-integration.test.ts's real, unmocked named-pipe coverage under an actual Windows kernel. --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c838789..b309b8d 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' From 85c28fbc4d3ea2c2819aacb135a8a118ba9d0f74 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:44:23 +0100 Subject: [PATCH 04/11] ci: re-add windows to the sea matrix and correct the readme limitation The rebase onto origin/main (which had removed Windows from the sea matrix in an earlier, since-superseded commit) dropped the re-addition this branch's own verify-windows commit assumed was already present - restoring it explicitly here. Also rewrites the readme's "no native Windows support" limitation, which the rest of this branch's work makes false: it now describes the real, still-true constraints (named pipe instead of a socket, a required auth line, an unverified procStart format) rather than a blanket unsupported claim. --- .github/workflows/ci.yml | 4 +++- README.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b309b8d..b83fca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,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. From 0318f0ea8eca695e76251f279294a30d767865f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 08:07:45 +0100 Subject: [PATCH 05/11] fix(windows): namespace named-pipe paths by socketDir instead of a filesystem path An explicit socketDir was made to win on every platform, including Windows, where socketPathForPid then returned a literal filesystem path such as "/tmp/xyz/1234.sock". Node has no filesystem-path AF_UNIX support on Windows at all, so net.Server.listen() rejected every one of these with EACCES regardless of which directory was named, breaking the bulk of the test suite (and any real caller supplying a socketDir) under a genuine Windows kernel. socketPathForPid now always returns a named-pipe path on Windows, and folds a caller-supplied socketDir into the pipe's own name (a short hash) rather than treating it as a real directory, since a pipe has no directory of its own to keep separate callers apart. pidFromSocketPath parses the pid from the final "-"-delimited segment so it round-trips either shape. Also fixes a WinProcInfo unit test that assumed powershell.exe is never on PATH: true on every POSIX runner this suite otherwise runs on, but false on the real Windows CI runners this branch is meant to support, where PowerShell is genuinely resolvable regardless of PATH contents. --- src/adapters/node/adapters-extra.test.ts | 61 ++++++++++++++---------- src/adapters/node/paths.ts | 34 +++++++++---- src/cc-peer-class.test.ts | 57 +++++++++++++++------- src/test/with-platform.ts | 18 +++++++ 4 files changed, 119 insertions(+), 51 deletions(-) create mode 100644 src/test/with-platform.ts diff --git a/src/adapters/node/adapters-extra.test.ts b/src/adapters/node/adapters-extra.test.ts index ff6df5f..fedc14d 100644 --- a/src/adapters/node/adapters-extra.test.ts +++ b/src/adapters/node/adapters-extra.test.ts @@ -19,6 +19,7 @@ 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"; /** A pid no OS will hand out, so ps exits nonzero for it. */ const IMPOSSIBLE_PID = 999_999_999; @@ -90,10 +91,13 @@ describe("WinProcInfo", () => { expect(await info.alive(IMPOSSIBLE_PID)).toBe(false); }); - test("lstart resolves to undefined when powershell.exe is not on this machine", async () => { - // Every test runner this suite actually runs on is POSIX, so powershell.exe genuinely does not exist here — this exercises the real "command not found" path, not a simulated one. Successful parsing of real PowerShell output is validated by the Windows CI job instead, which runs this class against a real powershell.exe. - expect(await info.lstart(process.pid)).toBeUndefined(); - }); + // 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", () => { @@ -122,32 +126,39 @@ describe("paths", () => { expect(pidFromSocketPath("\\\\.\\pipe\\cc-peer-not-a-pid")).toBe(0); }); - test("socketPathForPid honours an explicit socketDir", () => { - expect(socketPathForPid(4242, { socketDir: "/custom" })).toBe( - "/custom/4242.sock", - ); + 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 with no config", () => { - expect(socketPathForPid(4242)).toBe( - `${socketDirCandidates()[0]}/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", () => { - const original = process.platform; - Object.defineProperty(process, "platform", { - value: "win32", - configurable: true, - }); - try { + test("socketPathForPid produces a named-pipe path on Windows", async () => { + await withPlatform("win32", () => { expect(socketPathForPid(4242)).toBe("\\\\.\\pipe\\cc-peer-4242"); - } finally { - Object.defineProperty(process, "platform", { - value: original, - configurable: true, - }); - } + }); + }); + + 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", () => { diff --git a/src/adapters/node/paths.ts b/src/adapters/node/paths.ts index 71c48b0..bc29fe1 100644 --- a/src/adapters/node/paths.ts +++ b/src/adapters/node/paths.ts @@ -39,23 +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 other IPC mechanism there — - * see docs/PROTOCOL.md). Node dispatches to the right OS primitive from the - * path's own shape, so UdsTransport needs no change; only path construction - * does. The exact pipe name only needs to be unique per pid on this machine, - * not to match any specific value a real Windows Claude Code session uses. + * 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 { - // An explicit socketDir always wins, on every platform, matching socketDirCandidates' own precedence rule: it is a full override of automatic path construction, not merely a candidate to prefer. + 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`; } - if (isWindows()) return `${WINDOWS_PIPE_PREFIX}cc-peer-${pid.toString()}`; return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`; } @@ -79,10 +93,10 @@ export function keyFilePath( } export function pidFromSocketPath(socketPath: string): number { - // 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. + // 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.replace(/^cc-peer-/, ""), 10); + 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. diff --git a/src/cc-peer-class.test.ts b/src/cc-peer-class.test.ts index b5ecbca..aa352a9 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"; @@ -70,13 +70,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; } @@ -117,13 +117,34 @@ describe("CcPeer dependency-injected construction", () => { await peer.stop(); }); - test("on Windows, start() does not create a socket directory (an un-created directory makes the bind fail)", async () => { + test("on Windows, start() does not create a socket directory (a named pipe has none of its own)", async () => { const home = await tempHome(); - // No socketDir is created here at all, on purpose: a named pipe has no filesystem directory of its own, so if start() skipped the mkdir call (as it must on Windows), the underlying bind has no directory to fail on. This test cannot run on a real Windows kernel, so it observes the branch through the still-POSIX socket bind rejecting instead, which only happens if mkdir was genuinely skipped. - const peer = makePeer(home); - await expect( - withPlatform("win32", async () => peer.start()), - ).rejects.toThrow(); + // 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 () => { @@ -537,12 +558,16 @@ 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); }); await withPlatform("win32", async () => { - const socket = await rawClient(peer); + const socket = await rawClient(peer, socketPath); socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); const envelope = '\nhi\n'; @@ -577,7 +602,7 @@ describe("CcPeer inbound handling", () => { messages.push(m); }); await withPlatform("win32", async () => { - const socket = await rawClient(peer); + const socket = await rawClient(peer, socketPath); socket.write('{"type":"auth","token":"' + token + '"}\n'); const envelope = '\nhi\n'; 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, + }); + } +} From 98ec83fb6a8051496f538b598428ecf25b3a4437 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 08:08:00 +0100 Subject: [PATCH 06/11] fix(test): skip POSIX-only file-permission assertions on Windows Two file-transfer tests assumed POSIX permission-bit semantics that NTFS doesn't have: a written file's mode reads back as an actual owner-only 0600 (NTFS has no such bit; writeFile's mode option only ever toggles the read-only attribute), and chmod(0o000) makes a file genuinely unreadable (on NTFS it does not, so the file the code under test opens stays readable and the expiry branch never triggers). Both are guarded with test.skipIf(win32); the file-transfer code itself needs no change; POSIX runners still exercise both cases in full. --- src/domain/file-transfer-extra.test.ts | 49 +++++++++++++++++--------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/domain/file-transfer-extra.test.ts b/src/domain/file-transfer-extra.test.ts index f6b3d06..0c3e9f0 100644 --- a/src/domain/file-transfer-extra.test.ts +++ b/src/domain/file-transfer-extra.test.ts @@ -74,7 +74,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 +82,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 +133,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(); From f6d97ce86064883457d8cc11d818d97292795ea2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 08:55:43 +0100 Subject: [PATCH 07/11] fix(windows): recognise a drive-letter-rooted path as absolute in file-transfer validation materialiseAttachment checked "starts with /" to decide whether a descriptor's path was absolute before trusting it. A path staged on Windows is drive-letter-rooted (C:\...), so every real attachment received a "invalid transfer path" refusal there regardless of validity. Switched to node:path's isAbsolute, which the reference receiver's own contract ("an absolute path") actually means. --- src/domain/file-transfer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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"); From 3aa136fd82f24c33fc6aa2a6d3da95cfa61aa33c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 08:56:02 +0100 Subject: [PATCH 08/11] fix(test): stop asserting POSIX-only behaviour and paths on real Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several test fixtures assumed things that don't hold on a real Windows kernel: - Raw net/UdsTransport fixtures built literal filesystem paths (e.g. join(home, "lifecycle.sock")) directly rather than going through socketPathForPid, so they tried to bind a POSIX-shaped path Windows cannot use at all. A new shared testSocketPath helper builds a platform-appropriate path the same way socketPathForPid does, reusing the pid-agnostic fixtures needed here. - PsProcInfo's own describe block spawns a real ps and assumes POSIX signal-0/init-pid semantics; it never runs on Windows in production (CcPeer.create() only selects it when !isWindows()), so it is now skipped there — WinProcInfo already has its own coverage. - The foreign-token-tolerance and create()-selects-WinProcInfo tests encode POSIX-only protocol behaviour (Windows requires a valid auth line; WinProcInfo's own powershell.exe genuinely exists on a real Windows runner), both already covered by dedicated Windows-specific or real-Windows-integration tests elsewhere. - A key-file permissions test relied on chmod(0o000) removing read access, which NTFS does not do. --- src/adapters/node/adapters-extra.test.ts | 13 +- src/adapters/node/adapters.test.ts | 7 +- src/cc-peer-class.test.ts | 164 ++++++++++++----------- src/test/socket-path.ts | 21 +++ 4 files changed, 122 insertions(+), 83 deletions(-) create mode 100644 src/test/socket-path.ts diff --git a/src/adapters/node/adapters-extra.test.ts b/src/adapters/node/adapters-extra.test.ts index fedc14d..7238c8c 100644 --- a/src/adapters/node/adapters-extra.test.ts +++ b/src/adapters/node/adapters-extra.test.ts @@ -20,6 +20,7 @@ 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; @@ -44,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 () => { @@ -247,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; @@ -278,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; @@ -300,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; @@ -353,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/cc-peer-class.test.ts b/src/cc-peer-class.test.ts index aa352a9..06f6829 100644 --- a/src/cc-peer-class.test.ts +++ b/src/cc-peer-class.test.ts @@ -10,6 +10,7 @@ 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 { MessageTooLargeError, NoLiveInboxError, @@ -250,13 +251,16 @@ describe("CcPeer dependency-injected construction", () => { await peer.stop(); }); - test("on Windows, create() selects WinProcInfo, whose PowerShell probe fails on a non-Windows test runner", async () => { - // 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. - const home = await tempHome(); - await expect( - withPlatform("win32", async () => CcPeer.create(peerOptions(home))), - ).rejects.toThrow(NotStartedError); - }); + // 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(); @@ -293,7 +297,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()) { @@ -325,7 +329,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; @@ -368,7 +372,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; }); @@ -507,46 +511,52 @@ 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 () => { - 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(); - }); + // 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(); @@ -652,31 +662,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/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`); +} From 7700e66f7aa0989f895bcf25c65a511052b5f242 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 09:12:59 +0100 Subject: [PATCH 09/11] chore(lint): auto-fix on pnpm lint Adds --fix to the cached eslint invocation so a plain pnpm lint repairs auto-fixable violations instead of only reporting them, matching how lint-staged already runs eslint --fix on commit. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From e6dc6c05a2ddfc0fa89b8d199ee42efbef7a0057 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 09:14:19 +0100 Subject: [PATCH 10/11] fix(test): allow real subprocess-spawning tests more time on Windows ARM A real, non-DI CcPeer.create() spawns a genuine child process to read its own start time; unlike the dependency-injected fixtures elsewhere, there is no fake procInfo to short-circuit that cost. windows-11-arm CI runners were observed exceeding vitest's 5000ms default here, most plausibly from an x64-under-emulation PowerShell cold start. Introduces a shared, named timeout constant and applies it (per test or, where every test in a suite needs it, at the describe level) to every test that exercises a real CcPeer.create(), replacing the existing ad hoc 10_000/15_000/20_000 literals with one consistent value. --- src/cc-peer-class.test.ts | 17 +- src/test/timeouts.ts | 4 + test/api-extra.test.ts | 350 ++++++++++++++++--------------- test/api.test.ts | 177 ++++++++-------- test/integration.test.ts | 105 ++++++---- test/windows-integration.test.ts | 8 +- 6 files changed, 353 insertions(+), 308 deletions(-) create mode 100644 src/test/timeouts.ts diff --git a/src/cc-peer-class.test.ts b/src/cc-peer-class.test.ts index 06f6829..15f35a9 100644 --- a/src/cc-peer-class.test.ts +++ b/src/cc-peer-class.test.ts @@ -11,6 +11,7 @@ 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, @@ -244,12 +245,16 @@ 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")( diff --git a/src/test/timeouts.ts b/src/test/timeouts.ts new file mode 100644 index 0000000..065091b --- /dev/null +++ b/src/test/timeouts.ts @@ -0,0 +1,4 @@ +/** + * 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; 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 index 7a91463..60c2dc3 100644 --- a/test/windows-integration.test.ts +++ b/test/windows-integration.test.ts @@ -8,6 +8,7 @@ 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. @@ -18,6 +19,7 @@ async function tempHome(): Promise { 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(); @@ -31,7 +33,7 @@ describe.skipIf(process.platform !== "win32")( const roster = await peer.roster(); expect(roster).toEqual([]); await peer.stop(); - }, 15_000); + }); test("a raw client without a valid auth line is closed without delivering anything", async () => { const home = await tempHome(); @@ -56,7 +58,7 @@ describe.skipIf(process.platform !== "win32")( await once(socket, "close"); expect(messages).toHaveLength(0); await peer.stop(); - }, 15_000); + }); test("a raw client with the real published auth token is delivered", async () => { const home = await tempHome(); @@ -90,6 +92,6 @@ describe.skipIf(process.platform !== "win32")( expect(messages).toHaveLength(1); socket.destroy(); await peer.stop(); - }, 15_000); + }); }, ); From b155bb9a7c10112573be9fe65f023cbe33fe0e1b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 09:19:13 +0100 Subject: [PATCH 11/11] fix(test): give sweepSpool's batch-cap test headroom for its own I/O volume Creating, timing, stating and removing on the order of 200 individual files sequentially issues far more filesystem syscalls than vitest's 5000ms default timeout was ever sized for. A windows-latest CI run exceeded it here even though the identical code path stayed well within it on the same run's ubuntu and windows-11-arm legs, most plausibly NTFS latency under CI disk contention rather than anything platform-specific to fix in the code itself. --- src/domain/file-transfer-extra.test.ts | 39 +++++++++++++++----------- src/test/timeouts.ts | 5 ++++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/domain/file-transfer-extra.test.ts b/src/domain/file-transfer-extra.test.ts index 0c3e9f0..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-")); @@ -287,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); - } + 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); + await sweepSpool(home, now); - const remaining = await readdir(dir); - expect(remaining).toHaveLength(1); - }); + const remaining = await readdir(dir); + expect(remaining).toHaveLength(1); + }, + HEAVY_FS_IO_TEST_TIMEOUT_MS, + ); }); diff --git a/src/test/timeouts.ts b/src/test/timeouts.ts index 065091b..66ad0f9 100644 --- a/src/test/timeouts.ts +++ b/src/test/timeouts.ts @@ -2,3 +2,8 @@ * 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;