Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -107,12 +136,14 @@ jobs:
strategy:
fail-fast: false
matrix:
# darwin and linux only, two architectures each: win32 is deliberately excluded. Node's net module has no real AF_UNIX support on Windows — its own docs state the local domain there is implemented with named pipes, which must live under \\.\pipe\ or \\?\pipe\, not an arbitrary filesystem path (see nodejs/node#55979, nodejs/node#35008). Claude Code's own peer protocol is filesystem-path UDS end to end (this repo's own docs/PROTOCOL.md), so CcPeer.start() cannot bind a listening socket on Windows at all — confirmed directly: a windows-latest and a windows-11-arm leg both built a working .exe, then crashed before printing anything at all when the smoke step ran it, because the UDS listen() call itself fails. Shipping a Windows binary would only hand users something that cannot ever open its own socket. build-sea.ts still accepts win32 as a target for anyone building locally on Windows themselves; only the CI/release matrix excludes it.
# Every (platform, arch) pair on a standard runner image: two darwin legs, two linux, two win32. Windows was excluded here previously (a genuine Node limitation: no filesystem-path AF_UNIX support on Windows, only named pipes — nodejs/node#55979), but CcPeer now branches to a real named pipe on native Windows (see paths.ts and win-proc-info.ts), so both legs build a working binary again. build-sea.ts qualifies each asset name by process.platform AND process.arch so shared-OS legs cannot clobber each other's release upload, and gives win32 its .exe.
include:
- os: macos-latest
- os: macos-13
- os: ubuntu-latest
- os: ubuntu-24.04-arm
- os: windows-latest
- os: windows-11-arm
runs-on: ${{ matrix.os }}
timeout-minutes: 20
permissions:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 67 additions & 10 deletions src/adapters/node/adapters-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +19,8 @@ import { FsKeyStore } from "./fs-key-store.js";
import { FsRegistryStore } from "./fs-registry-store.js";
import { UdsTransport } from "./uds-transport.js";
import { keyFilePath } from "./paths.js";
import { withPlatform } from "../../test/with-platform.js";
import { testSocketPath } from "../../test/socket-path.js";

/** A pid no OS will hand out, so ps exits nonzero for it. */
const IMPOSSIBLE_PID = 999_999_999;
Expand All @@ -41,7 +45,8 @@ describe("errnoOf", () => {
});
});

describe("PsProcInfo", () => {
// PsProcInfo is the POSIX-only adapter (CcPeer.create() only ever selects it when !isWindows()); it spawns a real ps and assumes POSIX signal-0/init-pid semantics, neither of which holds on a real Windows kernel. WinProcInfo is exercised separately below.
describe.skipIf(process.platform === "win32")("PsProcInfo", () => {
const info = new PsProcInfo();

test("alive: own pid true, dead pid false, init pid EPERM-counts-as-live", async () => {
Expand Down Expand Up @@ -80,6 +85,23 @@ describe("PsProcInfo", () => {
});
});

describe("WinProcInfo", () => {
const info = new WinProcInfo();

test("alive shares PsProcInfo's own signal-0 probe: own pid true, dead pid false", async () => {
expect(await info.alive(process.pid)).toBe(true);
expect(await info.alive(IMPOSSIBLE_PID)).toBe(false);
});

// Windows' own CreateProcess search order finds powershell.exe via the system directories even with PATH cleared, so there is no reliable way to simulate "missing binary" on a real Windows runner; skipped there rather than asserting something no longer true. Every POSIX runner this suite otherwise runs on genuinely has no powershell.exe on PATH, exercising the real ENOENT path. Successful parsing of real PowerShell output is validated by the Windows CI job's own end-to-end run of this class against a real powershell.exe.
test.skipIf(process.platform === "win32")(
"lstart resolves to undefined when powershell.exe is not on this machine",
async () => {
expect(await info.lstart(process.pid)).toBeUndefined();
},
);
});

describe("paths", () => {
test("XDG_RUNTIME_DIR adds a candidate and is absent by default order", () => {
const had = process.env.XDG_RUNTIME_DIR;
Expand All @@ -101,10 +123,44 @@ describe("paths", () => {
expect(pidFromSocketPath("/tmp/cc-socks/foo.sock")).toBe(0);
});

test("socketPathForPid honours an explicit socketDir", () => {
expect(socketPathForPid(4242, { socketDir: "/custom" })).toBe(
"/custom/4242.sock",
);
test("pidFromSocketPath parses a named-pipe path by its own shape, regardless of the current platform", () => {
expect(pidFromSocketPath("\\\\.\\pipe\\cc-peer-4242")).toBe(4242);
expect(pidFromSocketPath("\\\\.\\pipe\\cc-peer-not-a-pid")).toBe(0);
});

test("socketPathForPid honours an explicit socketDir on POSIX", async () => {
await withPlatform("darwin", () => {
expect(socketPathForPid(4242, { socketDir: "/custom" })).toBe(
"/custom/4242.sock",
);
});
});

test("socketPathForPid falls back to the default candidate directory on POSIX with no config", async () => {
await withPlatform("darwin", () => {
expect(socketPathForPid(4242)).toBe(
`${socketDirCandidates()[0]}/4242.sock`,
);
});
});

test("socketPathForPid produces a named-pipe path on Windows", async () => {
await withPlatform("win32", () => {
expect(socketPathForPid(4242)).toBe("\\\\.\\pipe\\cc-peer-4242");
});
});

test("socketPathForPid namespaces the pipe name by socketDir on Windows, since a pipe has no directory of its own to keep callers apart", async () => {
await withPlatform("win32", () => {
const a = socketPathForPid(4242, { socketDir: "/tmp/cc-peer-a" });
const b = socketPathForPid(4242, { socketDir: "/tmp/cc-peer-b" });
expect(a).not.toBe(b);
expect(a).not.toBe("\\\\.\\pipe\\cc-peer-4242");
expect(a.startsWith("\\\\.\\pipe\\cc-peer-")).toBe(true);
expect(a.endsWith("-4242")).toBe(true);
expect(pidFromSocketPath(a)).toBe(4242);
expect(socketPathForPid(4242, { socketDir: "/tmp/cc-peer-a" })).toBe(a);
});
});

test("sessionsDir falls back to the real home without config", () => {
Expand Down Expand Up @@ -193,7 +249,7 @@ describe("FsRegistryStore", () => {
describe("UdsTransport connection lifecycle", () => {
test("readLines yields written lines and ends when the client disconnects", async () => {
const home = await tempHome();
const sockPath = join(home, "lifecycle.sock");
const sockPath = testSocketPath(home, "lifecycle");
const transport = new UdsTransport();
let received: string[] = [];
let iterationEnded = false;
Expand Down Expand Up @@ -224,7 +280,7 @@ describe("UdsTransport connection lifecycle", () => {

test("closing the listener destroys an accepted open connection", async () => {
const home = await tempHome();
const sockPath = join(home, "accepted.sock");
const sockPath = testSocketPath(home, "accepted");
const transport = new UdsTransport();
const listener = await transport.listen(sockPath, () => {
void 0;
Expand All @@ -246,7 +302,7 @@ describe("UdsTransport connection lifecycle", () => {

test("InboundConnection.close destroys the socket and ends iteration", async () => {
const home = await tempHome();
const sockPath = join(home, "closed-conn.sock");
const sockPath = testSocketPath(home, "closed-conn");
const transport = new UdsTransport();
const lines: string[] = [];
let ended = false;
Expand Down Expand Up @@ -299,7 +355,8 @@ describe("spawned child liveness", () => {
});
});

describe("key file permissions", () => {
// NTFS has no POSIX permission-bit model: chmod(0o000) there doesn't remove owner read access, so the file the code under test opens stays readable and the "unreadable" branch this test means to exercise never triggers.
describe.skipIf(process.platform === "win32")("key file permissions", () => {
test("an unreadable staged key surfaces as undefined rather than throwing", async () => {
const home = await tempHome();
const store = new FsKeyStore({ homeDir: home });
Expand Down
7 changes: 4 additions & 3 deletions src/adapters/node/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
pidFromSocketPath,
socketDirCandidates,
} from "./paths.js";
import { testSocketPath } from "../../test/socket-path.js";

async function tempHome(): Promise<string> {
return mkdtemp(join(tmpdir(), "cc-peer-test-"));
Expand All @@ -19,7 +20,7 @@ async function tempHome(): Promise<string> {
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 = "";
Expand Down Expand Up @@ -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<void>((resolve) => {
server.listen(live, () => {
Expand All @@ -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();
});
});
Expand Down
75 changes: 75 additions & 0 deletions src/adapters/node/cached-command.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return new Promise<boolean>((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<number, Promise<string | undefined>>();

async run(
pid: number,
command: string,
args: readonly string[],
): Promise<string | undefined> {
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<string | undefined>((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;
}
}
Loading
Loading