diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 211eeec..78bd49b 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -12,7 +12,7 @@ The consent design parallels agent-comms (ExaDev's cross-harness mesh; its conse ## Transport and identity -- Each interactive session binds `/tmp/cc-socks/.sock` (Unix socket, same-uid peers only). The registry entry `~/.claude/sessions/.json` records `messagingSocketPath`, `sessionId`, `bridgeSessionId`, `peerProtocol`, `peerFeatures` (`notify_idle`, `reply_across_default_dirs`, `artifact_yield`), name, status, and `procStart`. +- Each interactive session binds `/tmp/cc-socks/.sock` (Unix socket, same-uid peers only). The registry entry `~/.claude/sessions/.json` records `messagingSocketPath`, `sessionId`, `bridgeSessionId`, `peerProtocol`, `peerFeatures` (`notify_idle`, `reply_across_default_dirs`, `artifact_yield`), name, status, and `procStart`. **On Linux the socket instead binds under `$XDG_RUNTIME_DIR/cc-socks/.sock`** when that variable is set; a standalone peer registering there has to try that directory first, not `/tmp/cc-socks`, to land on the path a real Linux Claude Code session actually uses. - On startup the session writes a key file `~/.claude/sessions/..key` (mode 0600) where `` is `sha256` of the canonical socket path. Content: `{"peerToken":"<32 hex>","procStart":"...","pidDomain":"darwin"}`. A second in-process token, the `childToken`, is exported to subprocesses via `CLAUDE_CODE_MESSAGING_TOKEN` (with `CLAUDE_CODE_MESSAGING_SOCKET` and `CLAUDE_CODE_CHILD_SESSION=1`) — the inbox's startup banner documents the official injection recipe: `echo '{"type":"auth","token":"'$CLAUDE_CODE_MESSAGING_TOKEN'"}'; echo '{"type":"user",...}' | socat - UNIX-CONNECT:$CLAUDE_CODE_MESSAGING_SOCKET`. - Sender-side vetting (`Pe` in the uds-client chunk): the target's key file is read, its owning pid checked alive with a matching proc-start token (recycled-pid defence), and optionally the connected peer's pid/uid read from the kernel. A live socket with no vouching key is refused on platforms where auth is mandatory; on macOS auth is optional, and an unrecognised token degrades to the unauthenticated path rather than being rejected. - Address schemes (`Jf`): `uds:`, `bridge:`, `did:` (reserved — parsed as raw passthrough, no local resolution in 2.1.269), bare `*.sock` paths, and Windows named pipes. @@ -72,6 +72,8 @@ A standalone process becomes a first-class peer with three artifacts, all self-w **`procStart` must byte-match `LC_ALL=C TZ=UTC ps -o lstart= -p ` output** — the exact command Claude's own generators use, compared as a plain string. Two traps: macOS `ps` formats `lstart` per the locale (bare `ps` under en_GB emits day-before-month; `LC_ALL=C` emits ctime order), and the value is UTC, not local. A mismatch classifies the pid as `recycled` and the roster silently skips the entry. Store the forced-locale/UTC command's output verbatim. +**On Linux this `ps -o lstart=` convention does not apply.** A Linux Claude Code session writes `procStart` as field 22 (`starttime`) of `/proc//stat`, the process start time in clock ticks since boot rather than a date string, and expects the same for every entry it compares against. Reading past the parenthesised `comm` field (field 2, which may itself contain spaces or `)`) rather than splitting the raw line on whitespace keeps a command name from shifting which field lands at position 22. + The roster builder (`listLivePeerSessions`) reads all `~/.claude/sessions/.json` files — no daemon involvement; the filesystem is authoritative — and includes an entry when it has a `sock`, is not the caller's own, is not spare/parked, its socket accepts a live connect probe, and its pid is `present` (alive with matching `procStart`; `gone` entries are swept, `recycled` skipped). `nameSource` and `status` are whitelisted on read; out-of-list values parse harmlessly to undefined. Verified chain: a Python peer registered this way appears in `ListAgents` within seconds and receives native `SendMessage` by bare name (`from-name` resolves from the sender's own registry entry). diff --git a/src/adapters/node/adapters-extra.unit.test.ts b/src/adapters/node/adapters-extra.unit.test.ts index 4c0f3eb..82dc69f 100644 --- a/src/adapters/node/adapters-extra.unit.test.ts +++ b/src/adapters/node/adapters-extra.unit.test.ts @@ -15,6 +15,7 @@ import { FsKeyStore } from "./fs-key-store.js"; import { FsRegistryStore } from "./fs-registry-store.js"; import { keyFilePath } from "./paths.js"; import { withPlatform } from "../../test/with-platform.js"; +import { withEnv } from "../../test/with-env.js"; async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "cc-peer-ad-")); @@ -35,20 +36,18 @@ describe("errnoOf", () => { }); describe("paths", () => { - test("XDG_RUNTIME_DIR adds a candidate and is absent by default order", () => { - const had = process.env.XDG_RUNTIME_DIR; - delete process.env.XDG_RUNTIME_DIR; - expect(socketDirCandidates()).toHaveLength(2); - process.env.XDG_RUNTIME_DIR = "/xdg-run"; - try { - expect(socketDirCandidates().at(-1)).toBe("/xdg-run/cc-socks"); - } finally { - if (had === undefined) { - delete process.env.XDG_RUNTIME_DIR; - } else { - process.env.XDG_RUNTIME_DIR = had; - } - } + test("XDG_RUNTIME_DIR adds a candidate and is absent by default order", async () => { + await withEnv("XDG_RUNTIME_DIR", undefined, () => { + expect(socketDirCandidates()).toHaveLength(2); + }); + /* Off Linux the runtime dir is a trailing fallback, after the fixed + /tmp candidates; on Linux it is tried first instead (its own test + in adapters.unit.test.ts), since that's where Claude Code binds it. */ + await withEnv("XDG_RUNTIME_DIR", "/xdg-run", async () => { + await withPlatform("darwin", () => { + expect(socketDirCandidates().at(-1)).toBe("/xdg-run/cc-socks"); + }); + }); }); test("pidFromSocketPath returns 0 for a non-numeric basename", () => { diff --git a/src/adapters/node/adapters.unit.test.ts b/src/adapters/node/adapters.unit.test.ts index b8db2eb..e9bbc10 100644 --- a/src/adapters/node/adapters.unit.test.ts +++ b/src/adapters/node/adapters.unit.test.ts @@ -9,6 +9,8 @@ import { pidFromSocketPath, socketDirCandidates, } from "./paths.js"; +import { withPlatform } from "../../test/with-platform.js"; +import { withEnv } from "../../test/with-env.js"; async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "cc-peer-test-")); @@ -74,9 +76,22 @@ describe("FsRegistryStore", () => { }); describe("paths", () => { - test("derives pid from socket path and lists candidate dirs", () => { + test("derives pid from socket path and lists candidate dirs", async () => { expect(pidFromSocketPath("/tmp/cc-socks/4242.sock")).toBe(4242); expect(socketDirCandidates({ socketDir: "/custom" })).toEqual(["/custom"]); - expect(socketDirCandidates()[0]).toBe("/tmp/cc-socks"); + await withPlatform("darwin", () => { + expect(socketDirCandidates()[0]).toBe("/tmp/cc-socks"); + }); + }); + + test("puts $XDG_RUNTIME_DIR first on Linux, but an explicit socketDir still wins", async () => { + await withEnv("XDG_RUNTIME_DIR", "/run/user/0", async () => { + await withPlatform("linux", () => { + expect(socketDirCandidates()[0]).toBe("/run/user/0/cc-socks"); + expect(socketDirCandidates({ socketDir: "/custom" })).toEqual([ + "/custom", + ]); + }); + }); }); }); diff --git a/src/adapters/node/cached-command.unit.test.ts b/src/adapters/node/cached-command.unit.test.ts new file mode 100644 index 0000000..9a5e7ec --- /dev/null +++ b/src/adapters/node/cached-command.unit.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; + +import { CachedPidCommand } from "./cached-command.js"; + +describe("CachedPidCommand", () => { + test("a second call for the same pid inside the cache window is served from the cache", async () => { + const command = new CachedPidCommand(); + const first = await command.run(process.pid, process.execPath, [ + "-e", + "process.stdout.write('first')", + ]); + const second = await command.run(process.pid, process.execPath, [ + "-e", + "process.stdout.write('second')", + ]); + expect(first).toBe("first"); + expect(second).toBe(first); + }); +}); diff --git a/src/adapters/node/linux-proc-info.ts b/src/adapters/node/linux-proc-info.ts new file mode 100644 index 0000000..5659be3 --- /dev/null +++ b/src/adapters/node/linux-proc-info.ts @@ -0,0 +1,40 @@ +import { readFile } from "node:fs/promises"; + +import type { ProcInfo } from "../../ports/proc-info.js"; +import { signalZeroAlive } from "./cached-command.js"; + +/** Index into the fields after the closing `)` in /proc//stat: field 22 (starttime) is comm's own field 2 plus 20, so 22 - 3 = 19 fields into that remainder (state is the first). */ +const STARTTIME_FIELD_INDEX = 19; + +/** + * Linux Claude Code sessions record procStart as /proc//stat field 22 + * (starttime, in clock ticks since boot), not a `ps -o lstart=` date string; + * that format is a macOS convention PsProcInfo reproduces. Reading the same + * field the same way is what lets roster comparisons between a cc-peer entry + * and a real Linux Claude Code entry agree. comm (field 2) is parenthesised + * and may itself contain spaces or `)`, so the remaining fields are read from + * the text after the *last* `)`, never by splitting the raw line on spaces. + */ +export class LinuxProcInfo implements ProcInfo { + constructor(private readonly procRoot = "/proc") {} + + async alive(pid: number): Promise { + return signalZeroAlive(pid); + } + + async lstart(pid: number): Promise { + let stat: string; + try { + stat = await readFile(`${this.procRoot}/${pid.toString()}/stat`, "utf8"); + } catch { + return undefined; + } + const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim(); + const fields = afterComm.length > 0 ? afterComm.split(/\s+/) : []; + const starttime = fields[STARTTIME_FIELD_INDEX]; + + return starttime !== undefined && /^\d+$/.test(starttime) + ? starttime + : undefined; + } +} diff --git a/src/adapters/node/linux-proc-info.unit.test.ts b/src/adapters/node/linux-proc-info.unit.test.ts new file mode 100644 index 0000000..8dff994 --- /dev/null +++ b/src/adapters/node/linux-proc-info.unit.test.ts @@ -0,0 +1,89 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { selectProcInfo } from "../../cc-peer.js"; +import { withPlatform } from "../../test/with-platform.js"; +import { LinuxProcInfo } from "./linux-proc-info.js"; +import { PsProcInfo } from "./ps-proc-info.js"; +import { WinProcInfo } from "./win-proc-info.js"; + +async function tempProcRoot(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-proc-")); +} + +describe("LinuxProcInfo", () => { + const dirs: string[] = []; + + afterEach(async () => { + while (dirs.length > 0) { + const dir = dirs.pop(); + if (dir !== undefined) await rm(dir, { recursive: true, force: true }); + } + }); + + async function writeStat(pid: number, line: string): Promise { + const root = await tempProcRoot(); + dirs.push(root); + const procDir = join(root, pid.toString()); + await mkdir(procDir, { recursive: true }); + await writeFile(join(procDir, "stat"), line); + + return root; + } + + test("returns field 22 for a normal comm", async () => { + const root = await writeStat( + 4242, + "4242 (node) S 1 4242 4242 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 1 0 249560752 0 0 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0", + ); + const info = new LinuxProcInfo(root); + expect(await info.lstart(4242)).toBe("249560752"); + }); + + test("parses past a comm containing spaces and a closing paren", async () => { + const root = await writeStat( + 4343, + "4343 (my weird ) proc) S 1 4343 4343 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 1 0 99999 0 0 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0", + ); + const info = new LinuxProcInfo(root); + expect(await info.lstart(4343)).toBe("99999"); + }); + + test("a missing /proc//stat file reads as undefined", async () => { + const root = await tempProcRoot(); + dirs.push(root); + const info = new LinuxProcInfo(root); + expect(await info.lstart(9999)).toBeUndefined(); + }); + + test("nothing after the comm's closing paren reads as undefined", async () => { + const root = await writeStat(4545, "4545 (node)\n"); + const info = new LinuxProcInfo(root); + expect(await info.lstart(4545)).toBeUndefined(); + }); + + test("a non-numeric field 22 reads as undefined", async () => { + const root = await writeStat( + 4444, + "4444 (node) S 1 4444 4444 0 -1 4194560 100 0 0 0 1 2 0 0 20 0 1 0 not-a-number 0 0 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0", + ); + const info = new LinuxProcInfo(root); + expect(await info.lstart(4444)).toBeUndefined(); + }); +}); + +describe("selectProcInfo", () => { + test("picks LinuxProcInfo on linux, WinProcInfo on win32, PsProcInfo elsewhere", async () => { + await withPlatform("linux", () => { + expect(selectProcInfo()).toBeInstanceOf(LinuxProcInfo); + }); + await withPlatform("win32", () => { + expect(selectProcInfo()).toBeInstanceOf(WinProcInfo); + }); + await withPlatform("darwin", () => { + expect(selectProcInfo()).toBeInstanceOf(PsProcInfo); + }); + }); +}); diff --git a/src/adapters/node/paths.ts b/src/adapters/node/paths.ts index 0faff7c..74234bb 100644 --- a/src/adapters/node/paths.ts +++ b/src/adapters/node/paths.ts @@ -20,15 +20,24 @@ export function isWindows(): boolean { * 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. + * An explicit socketDir always overrides this order entirely, on every + * platform. */ export function socketDirCandidates( config: Readonly = {}, ): [string, ...string[]] { if (config.socketDir !== undefined) return [config.socketDir]; /* /tmp/cc-socks and /private/tmp/cc-socks (its realpath on macOS), plus the - XDG runtime and Termux variants the reference client also accepts. */ + XDG runtime variant the reference client also accepts. */ const runtimeDir = process.env.XDG_RUNTIME_DIR; + /* Linux Claude Code sessions bind under $XDG_RUNTIME_DIR/cc-socks, not + /tmp/cc-socks, so it has to be tried first there. Elsewhere the fixed + /tmp path still comes first, with the runtime dir kept as a fallback. */ + if (process.platform === "linux" && runtimeDir !== undefined) { + return [`${runtimeDir}/cc-socks`, "/tmp/cc-socks", "/private/tmp/cc-socks"]; + } + return [ "/tmp/cc-socks", "/private/tmp/cc-socks", diff --git a/src/cc-peer.ts b/src/cc-peer.ts index 9bd1b7e..443e42d 100644 --- a/src/cc-peer.ts +++ b/src/cc-peer.ts @@ -8,6 +8,7 @@ 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 { LinuxProcInfo } from "./adapters/node/linux-proc-info.js"; import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { @@ -49,6 +50,14 @@ export const CC_PEER_VERSION: string = packageJson.version; /** Default log sink: logs go nowhere unless a logger is provided. */ const sinkLog = (): void => undefined; +/** Picks the real process-info adapter for the current platform. Exported for direct unit coverage of the selection itself, separate from each adapter's own behaviour. */ +export function selectProcInfo(): ProcInfo { + if (isWindows()) return new WinProcInfo(); + if (process.platform === "linux") return new LinuxProcInfo(); + + return new PsProcInfo(); +} + /** How a target can be addressed. */ export type PeerRef = { pid: number } | { name: string } | { address: string }; @@ -124,7 +133,7 @@ export class CcPeer extends EventEmitter { transport: new UdsTransport(), registry: new FsRegistryStore(options), keys: new FsKeyStore(options), - procInfo: isWindows() ? new WinProcInfo() : new PsProcInfo(), + procInfo: selectProcInfo(), clock: new SystemClock(), }); await peer.start(); diff --git a/src/test/with-env.ts b/src/test/with-env.ts new file mode 100644 index 0000000..cdb5015 --- /dev/null +++ b/src/test/with-env.ts @@ -0,0 +1,34 @@ +/** + * Runs fn with process.env[key] set to value (undefined deletes it + * instead), always restoring the original value afterward even if fn + * throws. Mirrors withPlatform's shape, and for the same reason avoids + * plain `process.env[key] = original` / `delete process.env[key]`: eslint + * flags a direct assignment to a shared global made after an await + * elsewhere in the same function (require-atomic-updates) and a delete on a + * dynamically computed key (no-dynamic-delete). Object.defineProperty and + * Reflect.deleteProperty do the same thing without tripping either rule. + */ +export async function withEnv( + key: string, + value: string | undefined, + fn: () => T | Promise, +): Promise { + const descriptor = (v: string): PropertyDescriptor & { value: string } => ({ + value: v, + configurable: true, + writable: true, + enumerable: true, + }); + const original = process.env[key]; + if (value === undefined) Reflect.deleteProperty(process.env, key); + else Object.defineProperty(process.env, key, descriptor(value)); + try { + return await fn(); + } finally { + if (original === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + Object.defineProperty(process.env, key, descriptor(original)); + } + } +}