Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>.sock` (Unix socket, same-uid peers only). The registry entry `~/.claude/sessions/<pid>.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/<pid>.sock` (Unix socket, same-uid peers only). The registry entry `~/.claude/sessions/<pid>.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/<pid>.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/<pid>.<hash>.key` (mode 0600) where `<hash>` 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:<path>`, `bridge:<id>`, `did:<x>` (reserved — parsed as raw passthrough, no local resolution in 2.1.269), bare `*.sock` paths, and Windows named pipes.
Expand Down Expand Up @@ -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 <pid>` 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/<pid>/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/<pid>.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).
Expand Down
27 changes: 13 additions & 14 deletions src/adapters/node/adapters-extra.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return mkdtemp(join(tmpdir(), "cc-peer-ad-"));
Expand All @@ -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", () => {
Expand Down
19 changes: 17 additions & 2 deletions src/adapters/node/adapters.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return mkdtemp(join(tmpdir(), "cc-peer-test-"));
Expand Down Expand Up @@ -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",
]);
});
});
});
});
19 changes: 19 additions & 0 deletions src/adapters/node/cached-command.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 40 additions & 0 deletions src/adapters/node/linux-proc-info.ts
Original file line number Diff line number Diff line change
@@ -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/<pid>/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/<pid>/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<boolean> {
return signalZeroAlive(pid);
}

async lstart(pid: number): Promise<string | undefined> {
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;
}
}
89 changes: 89 additions & 0 deletions src/adapters/node/linux-proc-info.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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/<pid>/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);
});
});
});
11 changes: 10 additions & 1 deletion src/adapters/node/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathConfig> = {},
): [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",
Expand Down
11 changes: 10 additions & 1 deletion src/cc-peer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -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();
Expand Down
34 changes: 34 additions & 0 deletions src/test/with-env.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
key: string,
value: string | undefined,
fn: () => T | Promise<T>,
): Promise<T> {
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));
}
}
}