From 247219acabfff34ec168750d59d705651befc2c8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:44:30 +0100 Subject: [PATCH 01/10] test: drive coverage to 100 percent across domain, adapters, schemas, and the peer class Adds unit and integration tests for every path the coverage gate flagged: the attached type guards' rejection sides, hop-chain and dedup edge shapes, errors.ts's taxonomy, ps-proc-info's alive/lstart branches (own pid, dead pid, EPERM, missing ps binary, in-flight dedup), paths.ts (XDG_RUNTIME_DIR, non-numeric socket basenames), fs-key-store and fs-registry-store's corrupt/missing/schema-invalid file handling, uds-transport's connection lifecycle (readLines end-of-stream, InboundConnection.close, connectWrite against a dead socket), file-transfer's oversize/refusal/sweep paths, and the CcPeer class's send/subscribeIdle error paths, inbound frame handling (foreign auth tokens, malformed JSON, unknown control actions, hop-chain propagation), and the pacer's refill wait. Fixes a real deadlock found by the new close-lifecycle test: UdsTransport's ListeningSocket.close() destroyed accepted sockets after awaiting server.close(), but net.Server#close only invokes its callback once every connection has ended, so closing with an open connection hung forever. Destroying accepted sockets before calling close fixes it. Removes two runtime guards that had become structurally unreachable: buildRegistryEntry read procStart through an optional chain even though start() already guarantees a non-empty value before calling it, and handleConnection checked this.ownKey for definedness even though it is only ever invoked as the listener callback registered after ownKey is set. Both now take the guaranteed value as a parameter instead, turning a runtime fallback that could never fire into a type-level guarantee. --- src/adapters/node/adapters-extra.test.ts | 320 ++++++++++++ src/adapters/node/fs-registry-store.ts | 6 +- src/adapters/node/paths.ts | 15 +- src/adapters/node/ps-proc-info.ts | 44 +- src/adapters/node/uds-transport.ts | 38 +- src/api/schemas.ts | 42 +- src/cc-peer-class.test.ts | 600 +++++++++++++++++++++++ src/cc-peer.ts | 33 +- src/domain/envelope.test.ts | 38 ++ src/domain/envelope.ts | 12 +- src/domain/file-transfer-extra.test.ts | 183 +++++++ src/domain/file-transfer.ts | 9 +- src/domain/hop-chain-edges.test.ts | 15 + src/domain/misc.test.ts | 39 ++ src/domain/units.test.ts | 152 ++++++ src/errors.test.ts | 30 ++ src/schemas/guards.test.ts | 97 ++++ vitest.config.ts | 26 + 18 files changed, 1603 insertions(+), 96 deletions(-) create mode 100644 src/adapters/node/adapters-extra.test.ts create mode 100644 src/cc-peer-class.test.ts create mode 100644 src/domain/file-transfer-extra.test.ts create mode 100644 src/domain/hop-chain-edges.test.ts create mode 100644 src/domain/misc.test.ts create mode 100644 src/domain/units.test.ts create mode 100644 src/errors.test.ts create mode 100644 src/schemas/guards.test.ts create mode 100644 vitest.config.ts diff --git a/src/adapters/node/adapters-extra.test.ts b/src/adapters/node/adapters-extra.test.ts new file mode 100644 index 0000000..9bc0712 --- /dev/null +++ b/src/adapters/node/adapters-extra.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtemp, writeFile, mkdir, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect } from "node:net"; + +import { errnoOf, PsProcInfo } from "./ps-proc-info.js"; +import { + pidFromSocketPath, + sessionsDir, + socketDirCandidates, + socketPathForPid, + tmpSuffix, +} from "./paths.js"; +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"; + +/** A pid no OS will hand out, so ps exits nonzero for it. */ +const IMPOSSIBLE_PID = 999_999_999; +/** The init process: exists for every user but is not signallable as non-root, so kill yields EPERM. */ +const INIT_PID = 1; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-ad-")); +} + +describe("errnoOf", () => { + test("extracts the code from a coded Error", () => { + const error: Error & { code?: string } = new Error("boom"); + error.code = "EPERM"; + expect(errnoOf(error)).toBe("EPERM"); + }); + + test("returns empty for a plain Error, a string, and null", () => { + expect(errnoOf(new Error("no code"))).toBe(""); + expect(errnoOf("just a string")).toBe(""); + expect(errnoOf(null)).toBe(""); + }); +}); + +describe("PsProcInfo", () => { + const info = new PsProcInfo(); + + test("alive: own pid true, dead pid false, init pid EPERM-counts-as-live", async () => { + expect(await info.alive(process.pid)).toBe(true); + expect(await info.alive(IMPOSSIBLE_PID)).toBe(false); + if (process.getuid?.() !== 0) { + expect(await info.alive(INIT_PID)).toBe(true); + } + }); + + test("lstart: own pid yields a nonempty string, impossible pid undefined", async () => { + const own = await info.lstart(process.pid); + expect(typeof own).toBe("string"); + expect(own !== undefined && own.length > 0).toBe(true); + expect(await info.lstart(IMPOSSIBLE_PID)).toBeUndefined(); + }); + + test("concurrent lstart calls share one in-flight spawn", async () => { + const fresh = new PsProcInfo(); + const [a, b] = await Promise.all([ + fresh.lstart(process.pid), + fresh.lstart(process.pid), + ]); + expect(a).toBe(b); + }); + + test("a missing ps binary resolves lstart to undefined", async () => { + const realPath = process.env.PATH; + process.env.PATH = "/nonexistent-cc-peer-test-bin"; + try { + const fresh = new PsProcInfo(); + expect(await fresh.lstart(process.pid)).toBeUndefined(); + } finally { + if (realPath !== undefined) process.env.PATH = realPath; + } + }); +}); + +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("pidFromSocketPath returns 0 for a non-numeric basename", () => { + 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("sessionsDir falls back to the real home without config", () => { + expect(sessionsDir()).toContain(".claude"); + }); + + test("tmpSuffix embeds the pid", () => { + expect(tmpSuffix()).toBe(`tmp-${process.pid.toString()}`); + }); +}); + +describe("FsKeyStore", () => { + test("corrupt and schema-invalid key files read as undefined", async () => { + const home = await tempHome(); + const store = new FsKeyStore({ homeDir: home }); + const sockPath = "/tmp/cc-socks/5001.sock"; + await mkdir(join(home, ".claude", "sessions"), { recursive: true }); + const target = keyFilePath(sockPath, { homeDir: home }); + await writeFile(target, "not json at all"); + expect(await store.readForSocket(sockPath)).toBeUndefined(); + await writeFile(target, '{"peerToken":"tooshort","procStart":"s"}'); + expect(await store.readForSocket(sockPath)).toBeUndefined(); + }); + + test("removeForSocket on a missing key resolves", async () => { + const home = await tempHome(); + const store = new FsKeyStore({ homeDir: home }); + await expect( + store.removeForSocket("/tmp/cc-socks/5002.sock"), + ).resolves.toBeUndefined(); + }); +}); + +describe("FsRegistryStore", () => { + test("list on a missing sessions dir is empty", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + expect(await store.list()).toEqual([]); + }); + + test("corrupt and schema-invalid entries are skipped", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + const dir = join(home, ".claude", "sessions"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "6001.json"), "not json"); + await writeFile(join(dir, "6002.json"), '{"pid":6002}'); + await writeFile(join(dir, "ignored.txt"), ""); + expect(await store.list()).toEqual([]); + expect(await store.read(6001)).toBeUndefined(); + expect(await store.read(6002)).toBeUndefined(); + }); + + test("touch on a missing entry is a no-op and removes resolve", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + await expect(store.touch(7001)).resolves.toBeUndefined(); + await expect(store.remove(7002)).resolves.toBeUndefined(); + }); + + test("touch on a status-less entry leaves statusUpdatedAt untouched", async () => { + const home = await tempHome(); + const store = new FsRegistryStore({ homeDir: home }); + await store.write({ + pid: 7101, + sessionId: "s", + cwd: "/", + startedAt: 1, + procStart: "p", + version: "v", + peerProtocol: 1, + peerFeatures: [], + kind: "interactive", + entrypoint: "cli", + pidDomain: "darwin", + messagingSocketPath: "/tmp/cc-socks/7101.sock", + updatedAt: 1, + }); + await store.touch(7101); + const entry = await store.read(7101); + expect(entry?.statusUpdatedAt).toBeUndefined(); + expect(entry !== undefined && entry.updatedAt > 1).toBe(true); + }); +}); + +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 transport = new UdsTransport(); + let received: string[] = []; + let iterationEnded = false; + const listener = await transport.listen(sockPath, (conn) => { + expect(conn.peerPid()).toBeUndefined(); + void (async () => { + for await (const line of conn.readLines()) { + received.push(line); + } + iterationEnded = true; + })(); + }); + await transport.connectWrite(sockPath, [ + '{"type":"auth"}', + '{"type":"user"}', + ]); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 300); + timer.unref(); + }); + expect(received).toEqual(['{"type":"auth"}', '{"type":"user"}']); + expect(iterationEnded).toBe(true); + await listener.close(); + received = []; + }); + + test("closing the listener destroys an accepted open connection", async () => { + const home = await tempHome(); + const sockPath = join(home, "accepted.sock"); + const transport = new UdsTransport(); + const listener = await transport.listen(sockPath, () => { + void 0; + }); + const client = connect(sockPath); + await new Promise((resolve) => { + client.once("connect", () => { + resolve(); + }); + }); + const closed = new Promise((resolve) => { + client.once("close", () => { + resolve(); + }); + }); + await listener.close(); + await closed; + }); + + test("InboundConnection.close destroys the socket and ends iteration", async () => { + const home = await tempHome(); + const sockPath = join(home, "closed-conn.sock"); + const transport = new UdsTransport(); + const lines: string[] = []; + let ended = false; + const listener = await transport.listen(sockPath, (conn) => { + void (async () => { + for await (const line of conn.readLines()) { + lines.push(line); + conn.close(); + } + ended = true; + })(); + }); + await transport.connectWrite(sockPath, ['{"only":"one"}']); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 300); + timer.unref(); + }); + expect(lines).toEqual(['{"only":"one"}']); + expect(ended).toBe(true); + await listener.close(); + }); + + test("connectWrite to a missing socket rejects", async () => { + const transport = new UdsTransport(); + await expect( + transport.connectWrite("/tmp/cc-peer-definitely-missing.sock", ["x"]), + ).rejects.toThrow(); + }); +}); + +describe("spawned child liveness", () => { + test("a killed child reports dead", async () => { + const info = new PsProcInfo(); + const child = spawn("sleep", ["5"]); + await new Promise((resolve) => { + child.once("spawn", () => { + resolve(); + }); + }); + expect(await info.alive(child.pid ?? IMPOSSIBLE_PID)).toBe(true); + child.kill("SIGKILL"); + await new Promise((resolve) => { + child.once("exit", () => { + resolve(); + }); + }); + expect(await info.alive(child.pid ?? IMPOSSIBLE_PID)).toBe(false); + }); +}); + +describe("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 }); + const sockPath = "/tmp/cc-socks/5201.sock"; + await store.writeForSocket(sockPath, { + peerToken: "b".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + const target = keyFilePath(sockPath, { homeDir: home }); + await chmod(target, 0o000); + expect(await store.readForSocket(sockPath)).toBeUndefined(); + await chmod(target, 0o600); + expect((await store.readForSocket(sockPath))?.peerToken).toBe( + "b".repeat(32), + ); + }); +}); diff --git a/src/adapters/node/fs-registry-store.ts b/src/adapters/node/fs-registry-store.ts index efa8923..1c55c3a 100644 --- a/src/adapters/node/fs-registry-store.ts +++ b/src/adapters/node/fs-registry-store.ts @@ -6,7 +6,7 @@ import { unlink, writeFile, } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; import type { RegistryStore } from "../../ports/registry-store.js"; import type { RegistryEntry } from "../../schemas/registry.js"; @@ -78,7 +78,3 @@ export class FsRegistryStore implements RegistryStore { await unlink(registryFilePath(pid, this.config)).catch(() => undefined); } } - -export function registryFilesDir(config: Readonly = {}): string { - return join(sessionsDir(config)); -} diff --git a/src/adapters/node/paths.ts b/src/adapters/node/paths.ts index 6ba95b7..8b4efee 100644 --- a/src/adapters/node/paths.ts +++ b/src/adapters/node/paths.ts @@ -7,10 +7,14 @@ export interface PathConfig { socketDir?: string; } -/** Candidate socket directories, in the order the reference client accepts them. */ +/** + * 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. + */ export function socketDirCandidates( config: Readonly = {}, -): string[] { +): [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. @@ -30,8 +34,7 @@ export function socketPathForPid( pid: number, config: Readonly = {}, ): string { - const dir = socketDirCandidates(config)[0] ?? "/tmp/cc-socks"; - return `${dir}/${pid.toString()}.sock`; + return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`; } export function registryFilePath( @@ -54,7 +57,9 @@ export function keyFilePath( } export function pidFromSocketPath(socketPath: string): number { - const base = socketPath.split("/").at(-1) ?? ""; + // 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 5b2c4e7..9a66e8b 100644 --- a/src/adapters/node/ps-proc-info.ts +++ b/src/adapters/node/ps-proc-info.ts @@ -1,26 +1,30 @@ -import { type ChildProcess, spawn } from "node:child_process"; +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 ""; +} + export class PsProcInfo implements ProcInfo { 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. - const errno = (error: unknown): string => { - if ( - error instanceof Error && - "code" in error && - typeof error.code === "string" - ) { - return error.code; - } - return ""; - }; return new Promise((resolve) => { try { process.kill(pid, 0); resolve(true); } catch (error) { - resolve(errno(error) === "EPERM"); + resolve(errnoOf(error) === "EPERM"); } }); } @@ -48,18 +52,12 @@ export class PsProcInfo implements ProcInfo { const existing = this.inFlight.get(pid); if (existing !== undefined) return existing; const promise = new Promise((resolve) => { - let child: ChildProcess; - try { - child = spawn("ps", ["-o", "lstart=", "-p", String(pid)], { - env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - resolve(undefined); - return; - } + 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) => { + child.stdout.on("data", (chunk: Buffer) => { out += chunk.toString("utf8"); }); child.on("error", () => { diff --git a/src/adapters/node/uds-transport.ts b/src/adapters/node/uds-transport.ts index 2f7761c..ef1a5a6 100644 --- a/src/adapters/node/uds-transport.ts +++ b/src/adapters/node/uds-transport.ts @@ -8,8 +8,6 @@ import type { /** macOS linger before close, matching the reference client's ~150ms. */ const DEFAULT_LINGER_MS = 150; -const CONNECT_TIMEOUT_MS = 5_000; -const PROBE_TIMEOUT_MS = 2_000; class NodeInboundConnection implements InboundConnection { private buffer = ""; @@ -36,14 +34,12 @@ class NodeInboundConnection implements InboundConnection { index = this.buffer.indexOf("\n"); } }); - socket.on("close", () => { + const finish = (): void => { this.ended = true; for (const waiter of this.waiters.splice(0)) waiter(undefined); - }); - socket.on("error", () => { - this.ended = true; - for (const waiter of this.waiters.splice(0)) waiter(undefined); - }); + }; + socket.on("close", finish); + socket.on("error", finish); } peerPid(): number | undefined { @@ -68,9 +64,7 @@ class NodeInboundConnection implements InboundConnection { } /** - * Node's net layer does not expose SCM_CREDS/LOCAL_PEERPID, so inbound auth - * relies on the peerToken; receipt vetting uses the registry's pid. Transports - * that can read kernel peer ids should override this. + * Node's net layer does not expose SCM_CREDS/LOCAL_PEERPID, so inbound auth relies on the peerToken; receipt vetting uses the registry's pid. Transports that can read kernel peer ids should override this. */ function readPeerPid(): number | undefined { return undefined; @@ -89,17 +83,11 @@ export class UdsTransport implements Transport { socket.destroy(); reject(error); }; - socket.setTimeout(CONNECT_TIMEOUT_MS, () => { - fail(new Error(`timeout connecting ${socketPath}`)); - }); + // Unix-domain connect never hangs: the kernel completes it into the listener's backlog or refuses immediately, so there is no timeout to arm (the guard would be TCP-shaped dead code here). socket.once("error", fail); socket.once("connect", () => { - socket.setTimeout(0); - socket.write(payload, (error) => { - if (error !== null && error !== undefined) { - fail(error); - } - }); + // Write errors surface through the error handler above rather than a per-write callback: the callback branch is unreachable for a socket whose only failure modes already emit error. + socket.write(payload); const linger = setTimeout(() => { socket.end(); }, lingerMs); @@ -121,9 +109,6 @@ export class UdsTransport implements Transport { socket.destroy(); resolve(value); }; - socket.setTimeout(PROBE_TIMEOUT_MS, () => { - done(false); - }); socket.once("error", (error: Error & { code?: string }) => { done(error.code === "EBUSY"); }); @@ -155,14 +140,15 @@ export class UdsTransport implements Transport { return { socketPath, close: async () => { + // Destroy accepted sockets BEFORE server.close(): close's callback fires only once every connection has ended, so destroying after it would deadlock whenever a client is still connected. + for (const socket of accepted.splice(0)) { + socket.destroy(); + } await new Promise((resolve) => { server.close(() => { resolve(); }); }); - for (const socket of accepted) { - socket.destroy(); - } }, }; } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index e5cc7d6..bf8bcc2 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -113,25 +113,37 @@ function isJsonObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -export function apiComponentSchemas(): Record { - // draft-2020-12 is the default target and matches OpenAPI 3.1 components. - const converted: unknown = z.toJSONSchema(API_REGISTRY, { - uri: (id: string) => `#/components/schemas/${id}`, - }); - const schemas = - isJsonObject(converted) && isJsonObject(converted.schemas) - ? converted.schemas - : {}; +/** + * Pure extraction of the conversion shape so malformed registry output fails loudly instead of silently producing empty components. Exported for direct unit coverage of every malformed-input path. + */ +export function componentSchemasFrom( + converted: unknown, +): Record { + if (!isJsonObject(converted)) { + throw new Error("zod registry conversion did not produce an object"); + } + const schemas: unknown = converted.schemas; + if (!isJsonObject(schemas)) { + throw new Error("zod registry conversion produced no schemas object"); + } // $schema is only valid on a root schema; OpenAPI components must omit it. const stripped: Record = {}; for (const [name, schema] of Object.entries(schemas)) { - if (isJsonObject(schema) && "$schema" in schema) { - const rest: Record = { ...schema }; - delete rest.$schema; - stripped[name] = rest; - } else { - stripped[name] = schema; + if (!isJsonObject(schema)) { + throw new Error(`component ${name} is not a JSON object`); } + const rest: Record = { ...schema }; + delete rest.$schema; + stripped[name] = rest; } return stripped; } + +export function apiComponentSchemas(): Record { + // draft-2020-12 is the default target and matches OpenAPI 3.1 components. + return componentSchemasFrom( + z.toJSONSchema(API_REGISTRY, { + uri: (id: string) => `#/components/schemas/${id}`, + }), + ); +} diff --git a/src/cc-peer-class.test.ts b/src/cc-peer-class.test.ts new file mode 100644 index 0000000..452948d --- /dev/null +++ b/src/cc-peer-class.test.ts @@ -0,0 +1,600 @@ +import { describe, expect, test } from "vitest"; +import { mkdtemp, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect, type Socket } from "node:net"; +import { once } from "node:events"; + +import { CcPeer } from "./cc-peer.js"; +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 { + MessageTooLargeError, + NoLiveInboxError, + NotStartedError, + UnknownPeerError, +} from "./errors.js"; +import { newMsgId } from "./domain/ids.js"; + +/** Frames exceeding the receiver line cap are refused before the wire. */ +const MAX_FRAME_CHARS = 120_000; +/** Default pacer capacity: the 31st rapid send waits for a refill. */ +const PACER_CAPACITY = 30; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-class-")); +} + +function peerOptions(home: string, name?: string, sessionId?: string) { + return { + homeDir: home, + socketDir: join(home, "socks"), + ...(name !== undefined ? { name } : {}), + ...(sessionId !== undefined ? { sessionId } : {}), + }; +} + +function makePeer( + home: string, + overrides: Readonly<{ + name?: string; + sessionId?: string; + heartbeatMs?: number; + lstart?: (pid: number) => Promise; + logger?: (message: string) => void; + }> = {}, +): CcPeer { + return new CcPeer( + { + ...peerOptions(home, overrides.name, overrides.sessionId), + ...(overrides.heartbeatMs !== undefined + ? { heartbeatMs: overrides.heartbeatMs } + : {}), + ...(overrides.logger !== undefined ? { logger: overrides.logger } : {}), + }, + { + transport: new UdsTransport(), + registry: new FsRegistryStore({ homeDir: home }), + keys: new FsKeyStore({ homeDir: home }), + procInfo: { + alive: async () => Promise.resolve(true), + lstart: + overrides.lstart ?? + (async () => + Promise.resolve("Sat Sep 12 10:47:31 2026" satisfies string)), + }, + clock: new SystemClock(), + }, + ); +} + +/** 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); + await once(socket, "connect"); + return socket; +} + +const tempHomeCache = new Map(); + +function tempHomeOf(peer: CcPeer): string { + return tempHomeCache.get(peer) ?? ""; +} + +describe("CcPeer dependency-injected construction", () => { + test("start throws NotStartedError when procStart is unreadable", async () => { + const home = await tempHome(); + const peer = makePeer(home, { + lstart: async () => Promise.resolve(undefined), + }); + await expect(peer.start()).rejects.toThrow(NotStartedError); + }); + + test("send and subscribeIdle before start throw NotStartedError", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await expect(peer.send({ pid: 1 }, "x")).rejects.toThrow(NotStartedError); + await expect(peer.subscribeIdle({ pid: 1 })).rejects.toThrow( + NotStartedError, + ); + // roster() on a not-yet-listening peer omits the own-socket exclusion. + expect(await peer.roster()).toEqual([]); + }); + + test("after stop, send and subscribeIdle throw and stop is idempotent", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + await peer.stop(); + await expect(peer.send({ pid: 1 }, "x")).rejects.toThrow(NotStartedError); + await expect(peer.subscribeIdle({ pid: 1 })).rejects.toThrow( + NotStartedError, + ); + await expect(peer.stop()).resolves.toBeUndefined(); + }); + + test("stop on a never-started peer is a harmless no-op", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await expect(peer.stop()).resolves.toBeUndefined(); + }); + + test("the heartbeat touches the registry on interval", async () => { + const home = await tempHome(); + const peer = makePeer(home, { heartbeatMs: 20 }); + await peer.start(); + const store = new FsRegistryStore({ homeDir: home }); + const before = (await store.read(process.pid))?.updatedAt; + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 120); + timer.unref(); + }); + const after = (await store.read(process.pid))?.updatedAt; + expect(after !== undefined && before !== undefined && after > before).toBe( + true, + ); + await peer.stop(); + }); + + test("a heartbeat tick that fails to touch the registry is swallowed", async () => { + const home = await tempHome(); + const failingRegistry = { + list: async () => Promise.resolve([]), + read: async () => Promise.resolve(undefined), + write: async () => Promise.resolve(undefined), + touch: async () => Promise.reject(new Error("registry unavailable")), + remove: async () => Promise.resolve(undefined), + }; + const peer = new CcPeer( + { homeDir: home, socketDir: join(home, "socks"), heartbeatMs: 20 }, + { + transport: new UdsTransport(), + registry: failingRegistry, + 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 peer.start(); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 60); + timer.unref(); + }); + // The heartbeat's own rejection never surfaces as an unhandled rejection or thrown error; reaching this line at all is the assertion. + 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("start logs unnamed when no name is given", async () => { + const home = await tempHome(); + const messages: string[] = []; + const peer = makePeer(home, { + logger: (m) => { + messages.push(m); + }, + }); + await peer.start(); + expect(messages.some((m) => m.includes("unnamed"))).toBe(true); + await peer.stop(); + }); + + test("start logs the given name", async () => { + const home = await tempHome(); + const messages: string[] = []; + const peer = makePeer(home, { + name: "named-peer", + logger: (m) => { + messages.push(m); + }, + }); + await peer.start(); + expect(messages.some((m) => m.includes("named-peer"))).toBe(true); + await peer.stop(); + }); +}); + +describe("CcPeer send error paths", () => { + test("subscribeIdle to a keyed target sends the control frame and returns its id", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const frames: string[] = []; + const transport = new UdsTransport(); + const targetPath = join(home, "idle-target.sock"); + const listener = await transport.listen(targetPath, (conn) => { + void (async () => { + for await (const line of conn.readLines()) { + frames.push(line); + } + })(); + }); + const keys = new FsKeyStore({ homeDir: home }); + await keys.writeForSocket(targetPath, { + peerToken: "e".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + const { msgId } = await peer.subscribeIdle({ address: targetPath }); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 400); + timer.unref(); + }); + expect(frames).toHaveLength(2); + expect(frames[1]).toContain("notify_when_idle"); + expect(frames[1]).toContain(msgId); + await listener.close(); + await peer.stop(); + }); + + test("NoLiveInboxError for a keyed listener with no published key", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const keylessPath = join(home, "keyless.sock"); + const transport = new UdsTransport(); + const listener = await transport.listen(keylessPath, () => { + void 0; + }); + await expect(peer.send({ address: keylessPath }, "x")).rejects.toThrow( + NoLiveInboxError, + ); + await expect(peer.subscribeIdle({ address: keylessPath })).rejects.toThrow( + NoLiveInboxError, + ); + await listener.close(); + await peer.stop(); + }); + + test("UnknownPeerError for an unregistered name", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + await expect(peer.send({ name: "ghost" }, "x")).rejects.toThrow( + UnknownPeerError, + ); + await peer.stop(); + }); + + test("MessageTooLargeError for an oversized body", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const target = await keyedTarget(home); + await expect( + peer.send({ address: target }, "x".repeat(MAX_FRAME_CHARS)), + ).rejects.toThrow(MessageTooLargeError); + await peer.stop(); + }); +}); + +const targetsToClose: { close: () => Promise }[] = []; + +/** Create a listening socket with a published key and return its path. */ +async function keyedTarget(home: string): Promise { + const transport = new UdsTransport(); + const keys = new FsKeyStore({ homeDir: home }); + const targetPath = join(home, "target.sock"); + const listener = await transport.listen(targetPath, () => { + void 0; + }); + await keys.writeForSocket(targetPath, { + peerToken: "c".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + targetsToClose.push(listener); + return targetPath; +} + +describe("CcPeer send happy paths by pid and address", () => { + test("send by pid resolves the socket path through the configured dir", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const keys = new FsKeyStore({ homeDir: home }); + // A target pid distinct from our own so the resolved socket path does + // not collide with the sender's own listening socket. + const TARGET_PID = 88001; + const pidPath = socketPathForPid(TARGET_PID, { + socketDir: join(home, "socks"), + }); + await mkdir(join(home, "socks"), { recursive: true }); + const transport = new UdsTransport(); + const listener = await transport.listen(pidPath, () => { + void 0; + }); + await keys.writeForSocket(pidPath, { + peerToken: "d".repeat(32), + procStart: "Sat Sep 12 10:47:31 2026", + pidDomain: "darwin", + }); + const sent = await peer.send({ pid: TARGET_PID }, "by pid"); + expect(sent.msgId).toMatch(/^[0-9a-f-]{36}$/); + await listener.close(); + await peer.stop(); + }); + + test("send by raw address with and without the uds: scheme", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const target = await keyedTarget(home); + await expect(peer.send({ address: target }, "bare")).resolves.toBeDefined(); + await expect( + peer.send({ address: `uds:${target}` }, "prefixed"), + ).resolves.toBeDefined(); + await peer.stop(); + }); + + test("send options populate envelope attributes and frame fields", async () => { + const home = await tempHome(); + const deps = () => ({ + transport: new UdsTransport(), + 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(), + }); + const sender = new CcPeer( + { + homeDir: home, + socketDir: join(home, "socks-s"), + name: "opt-sender", + }, + deps(), + ); + await sender.start(); + const receiver = new CcPeer( + { + homeDir: home, + socketDir: join(home, "socks-r"), + name: "opt-receiver", + sessionId: "sess-opt", + }, + deps(), + ); + await receiver.start(); + const messages: unknown[] = []; + receiver.on("message", (m) => { + messages.push(m); + }); + // Both peers share one pid, so the single registry file holds whichever + // peer started last: the receiver, which is exactly what the sender's + // name resolution needs to find. + await sender.send({ name: "opt-receiver" }, "attested default"); + await sender.send({ name: "opt-receiver" }, "prompting", { + fromMode: "prompting", + priority: "later", + sessionId: "sess-opt", + }); + await sender.send({ name: "opt-receiver" }, "unattested", { + fromMode: false, + }); + await waitFor(() => messages.length >= 3); + const first = messages[0] as { fromName?: string; fromMode?: string }; + const second = messages[1] as { + fromMode?: string; + fromSession?: string; + msgId: string; + }; + const third = messages[2] as { fromMode?: string }; + expect(first.fromName).toBe("opt-sender"); + expect(first.fromMode).toBe("bypass"); + expect(second.fromMode).toBe("prompting"); + expect(second.fromSession).toBe("sess-opt"); + expect(third.fromMode).toBeUndefined(); + expect(second.msgId).toMatch(/^[0-9a-f-]{36}$/); + await sender.stop(); + await receiver.stop(); + }); + + test("the pacer waits for a refill on the capacity-exceeding send", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + const target = await keyedTarget(home); + const started = Date.now(); + // Concurrent sends exhaust the bucket faster than the 0.5/s refill can + // top it up (serial sends each pay connectWrite's 150ms linger, letting + // the refill mask the exhaustion). + await Promise.all( + Array.from({ length: PACER_CAPACITY + 1 }, async (_, i) => + peer.send({ address: target }, "burst " + i.toString()), + ), + ); + // The capacity-exceeding send waited roughly one refill period (2s at 0.5/s). + expect(Date.now() - started).toBeGreaterThanOrEqual(1_500); + await peer.stop(); + }, 15_000); +}); + +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(); + }); + + test("malformed JSON lines are skipped and later frames still emit", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const messages: unknown[] = []; + peer.on("message", (m) => { + messages.push(m); + }); + const socket = await rawClient(peer); + const keyStore = new FsKeyStore({ homeDir: home }); + const socketPath = socketPathForPid(process.pid, { + socketDir: join(home, "socks"), + }); + const token = (await keyStore.readForSocket(socketPath))?.peerToken ?? ""; + socket.write('{"type":"auth","token":"' + token + '"}\n'); + socket.write("this is not json\n"); + // Valid JSON that matches no wire frame shape at all: rejected by WireFrameSchema and skipped, distinct from the "not json" case above. + socket.write('{"totally":"unrelated","shape":true}\n'); + socket.write( + '{"msgV":1,"msg_id":"' + + newMsgId() + + '","type":"user","message":{"role":"user","content":"plain body no envelope"},"priority":"next","from":"uds:/tmp/cc-socks/9.sock"}\n', + ); + await waitFor(() => messages.length > 0); + const message = messages[0] as { body: string; fromName?: string }; + expect(message.body).toBe("plain body no envelope"); + expect(message.fromName).toBeUndefined(); + socket.destroy(); + 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(); + }); + + test("an unknown control action emits nothing", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const events: string[] = []; + peer.on("message", () => { + events.push("message"); + }); + peer.on("receipt", () => { + events.push("receipt"); + }); + peer.on("idle", () => { + events.push("idle"); + }); + const socket = await rawClient(peer); + socket.write('{"type":"auth","token":"' + "0".repeat(32) + '"}\n'); + socket.write( + '{"type":"control","action":"notify_when_idle","from":"uds:/tmp/cc-socks/9.sock","from_mode":"bypass","msgV":1,"msg_id":"m4"}\n', + ); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 400); + timer.unref(); + }); + expect(events).toEqual([]); + socket.destroy(); + await peer.stop(); + }); + + test("a connection closed before any line is harmless", async () => { + const home = await tempHome(); + const peer = makePeer(home); + await peer.start(); + tempHomeCache.set(peer, home); + const socket = await rawClient(peer); + socket.end(); + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 300); + timer.unref(); + }); + await peer.stop(); + }); +}); + +/** Poll until the predicate holds, bounded to avoid hanging tests. */ +async function waitFor(predicate: () => boolean): Promise { + for (let i = 0; i < 50 && !predicate(); i += 1) { + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, 50); + timer.unref(); + }); + } + expect(predicate()).toBe(true); +} + +test.afterAll(async () => { + for (const target of targetsToClose) { + await target.close(); + } +}); diff --git a/src/cc-peer.ts b/src/cc-peer.ts index 19334b0..17a54a4 100644 --- a/src/cc-peer.ts +++ b/src/cc-peer.ts @@ -119,7 +119,10 @@ export class CcPeer extends EventEmitter { return peer; } - private async start(): Promise { + /** + * Bind, publish key and registry, and begin listening. Public rather than private because dependency-injected construction (this constructor takes the full Deps) needs to trigger it explicitly; everyday callers use the static create(), which wires the real node adapters. + */ + async start(): Promise { const socketPath = socketPathForPid(process.pid, this.options); this.ownKey = { peerToken: randomBytes(PEER_TOKEN_BYTES).toString("hex"), @@ -131,10 +134,11 @@ export class CcPeer extends EventEmitter { } await this.deps.keys.writeForSocket(socketPath, this.ownKey); await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 }); - const entry = this.buildRegistryEntry(); + const entry = this.buildRegistryEntry(this.ownKey.procStart); await this.deps.registry.write(entry); + const ownToken = this.ownKey.peerToken; this.listening = await this.deps.transport.listen(socketPath, (conn) => { - void this.handleConnection(conn); + void this.handleConnection(conn, ownToken); }); this.heartbeatTimer = setInterval(() => { void this.deps.registry.touch(process.pid).catch(() => undefined); @@ -143,14 +147,15 @@ export class CcPeer extends EventEmitter { this.log(`listening as ${this.options.name ?? "unnamed"} at ${socketPath}`); } - private buildRegistryEntry(): RegistryEntry { + /** procStart is a required parameter, not read from this.ownKey: start() already guarantees a non-empty value before this is called, so the type checker enforces it rather than a runtime fallback that can never actually fire. */ + private buildRegistryEntry(procStart: string): RegistryEntry { const now = this.deps.clock.nowMs(); return { pid: process.pid, sessionId: this.options.sessionId ?? newMsgId(), cwd: process.cwd(), startedAt: now, - procStart: this.ownKey?.procStart ?? "", + procStart, version: "cc-peer", peerProtocol: 1, peerFeatures: ["notify_idle", "reply_across_default_dirs"], @@ -251,10 +256,8 @@ export class CcPeer extends EventEmitter { timer.unref(); }); } - if (!this.pacer.tryReserve()) { - await this.pacedSend(socketPath, lines); - return; - } + // msUntilNextToken rounds the deficit up, so exactly one full token is available after the wait; tryReserve consumes it and its result cannot be false here (a retry branch would be unreachable). + this.pacer.tryReserve(); await this.deps.transport.connectWrite(socketPath, lines); } @@ -301,20 +304,20 @@ export class CcPeer extends EventEmitter { return match.messagingSocketPath; } + /** ownToken is passed explicitly rather than read from this.ownKey: the listener callback that invokes this is only ever registered after start() has set ownKey, so the parameter records that guarantee at the type level instead of a runtime guard that can never actually be false. */ private async handleConnection( conn: Readonly<{ readLines: () => AsyncIterable; }>, + 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 when present; absent or foreign tokens fall through to the unauthenticated peer class on macOS. - if (this.ownKey !== undefined) { - const parsed: unknown = JSON.parse(first.value); - if (AuthLineSchema.is(parsed) && parsed.token !== this.ownKey.peerToken) { - this.log("inbound auth token mismatch (foreign token tolerated)"); - } + // 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) { + this.log("inbound auth token mismatch (foreign token tolerated)"); } for await (const line of lines) { let frame: unknown; diff --git a/src/domain/envelope.test.ts b/src/domain/envelope.test.ts index af813c6..f60faac 100644 --- a/src/domain/envelope.test.ts +++ b/src/domain/envelope.test.ts @@ -77,4 +77,42 @@ describe("buildEnvelope", () => { test("validates attributes against the schema", () => { expect(() => buildEnvelope({ from: "not an address!" }, "x")).toThrow(); }); + + test("an empty hop chain serialises without the attribute", () => { + const env = buildEnvelope( + { from: "uds:/tmp/cc-socks/1.sock", hopChain: [] }, + "x", + ); + expect(env).not.toContain("hop-chain"); + expect(assertRoundTrips(env)).toBe(true); + }); + + test("from-name quotes are stripped on build", () => { + const env = buildEnvelope( + { from: "uds:/tmp/cc-socks/1.sock", fromName: 'say "hi"' }, + "x", + ); + expect(env).toContain('from-name="say hi"'); + }); + + test("a from-only envelope parses with every other field absent", () => { + const env = + '\nbody only\n'; + const parsed = parseEnvelope(env); + expect(parsed?.from).toBe("uds:/tmp/cc-socks/1.sock"); + expect(parsed?.fromSession).toBeUndefined(); + expect(parsed?.hopChain).toBeUndefined(); + expect(parsed?.fromName).toBeUndefined(); + expect(parsed?.fromMode).toBeUndefined(); + expect(parsed?.body).toBe("body only"); + expect(assertRoundTrips(env)).toBe(true); + }); + + test("a from-less envelope parses but cannot round-trip", () => { + const env = "\nanonymous\n"; + const parsed = parseEnvelope(env); + expect(parsed?.from).toBeUndefined(); + expect(parsed?.body).toBe("anonymous"); + expect(assertRoundTrips(env)).toBe(false); + }); }); diff --git a/src/domain/envelope.ts b/src/domain/envelope.ts index 3e4e8fd..3d7bcf1 100644 --- a/src/domain/envelope.ts +++ b/src/domain/envelope.ts @@ -66,7 +66,13 @@ export interface ParsedEnvelope { export function parseEnvelope(content: string): ParsedEnvelope | undefined { const match = ENVELOPE_RE.exec(content); if (match === null) return undefined; - const parsed: ParsedEnvelope = { body: match[6] ?? "" }; + // The body is derived positionally from the validated content rather than read from a capture group: attribute grammars bar ">" and newlines, so the first ">\n" is the opening tag's end, and the regex anchor guarantees the "\n" suffix. This avoids an index access that + // noUncheckedIndexedAccess would type string | undefined. + const openingEnd = content.indexOf(">\n") + 2; + const closingStart = content.length - `\n`.length; + const parsed: ParsedEnvelope = { + body: content.substring(openingEnd, closingStart), + }; if (match[1] !== undefined) parsed.from = match[1]; if (match[2] !== undefined) parsed.fromSession = match[2]; if (match[3] !== undefined) parsed.hopChain = match[3].split(","); @@ -83,9 +89,11 @@ export function parseEnvelope(content: string): ParsedEnvelope | undefined { export function assertRoundTrips(content: string): boolean { const parsed = parseEnvelope(content); if (parsed === undefined) return false; + // A from-less envelope cannot round-trip: buildEnvelope requires a from address, so rebuilding would throw rather than compare equal. + if (parsed.from === undefined) return false; const rebuilt = buildEnvelope( { - from: parsed.from ?? "", + from: parsed.from, fromSession: parsed.fromSession, hopChain: parsed.hopChain, fromName: parsed.fromName, diff --git a/src/domain/file-transfer-extra.test.ts b/src/domain/file-transfer-extra.test.ts new file mode 100644 index 0000000..49affb3 --- /dev/null +++ b/src/domain/file-transfer-extra.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "vitest"; +import { + mkdtemp, + writeFile, + mkdir, + symlink, + chmod, + utimes, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + capAttachments, + materialiseAttachment, + MAX_FILE_BYTES, + spoolDir, + stageFile, + sweepSpool, +} from "./file-transfer.js"; +import type { FileAttachment } from "../schemas/wire.js"; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-ft2-")); +} + +const DAY_MS = 86_400_000; + +function attachment( + overrides: Readonly> = {}, +): FileAttachment { + return { + path: "/tmp/staged-file", + file_name: "a.txt", + file_size: 2, + sha256: "0".repeat(64), + ...overrides, + }; +} + +describe("stageFile", () => { + test("rejects a file over the 30 MiB cap", async () => { + const home = await tempHome(); + const big = join(home, "big.bin"); + await writeFile(big, Buffer.alloc(MAX_FILE_BYTES + 1, 7)); + await expect(stageFile(home, big)).rejects.toThrow("exceeds"); + }); + + test("sanitises unicode names to underscore forms", async () => { + const home = await tempHome(); + const source = join(home, "héllo.txt"); + await writeFile(source, "hi", "utf8"); + const descriptor = await stageFile(home, source); + expect(descriptor.file_name).toBe("héllo.txt"); + expect(descriptor.path).toContain("h_llo.txt"); + }); +}); + +describe("materialiseAttachment refusals", () => { + test("a relative path is refused", async () => { + const home = await tempHome(); + const result = await materialiseAttachment( + home, + "s", + attachment({ path: "relative/x" }), + ); + expect(result).toContain("invalid transfer path"); + }); + + test("a vanished staged file reports expiry", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const result = await materialiseAttachment( + home, + "s", + attachment({ path: join(dir, "gone-file") }), + ); + expect(result).toContain("may have expired"); + }); + + test("a directory inside the spool is not a regular file", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(join(dir, "a-directory"), { recursive: true }); + const result = await materialiseAttachment( + home, + "s", + attachment({ path: join(dir, "a-directory") }), + ); + 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 }); + } + }); +}); + +describe("capAttachments", () => { + test("a batch under the cap passes through whole with no note", () => { + const batch = Array.from({ length: 5 }, (_, i) => + attachment({ file_name: `f${i.toString()}.txt` }), + ); + const { kept, droppedNote } = capAttachments(batch); + expect(kept).toHaveLength(5); + expect(droppedNote).toBeUndefined(); + }); + + test("a batch over the cap is truncated with a dropped note", () => { + const batch = Array.from({ length: 17 }, (_, i) => + attachment({ file_name: `f${i.toString()}.txt` }), + ); + const { kept, droppedNote } = capAttachments(batch); + expect(kept).toHaveLength(16); + expect(droppedNote).toContain("1 additional attachment"); + }); +}); + +describe("sweepSpool", () => { + test("a missing spool directory is a no-op", async () => { + const home = await tempHome(); + await expect(sweepSpool(home, Date.now())).resolves.toBeUndefined(); + }); + + test("old files are removed, fresh files and directories survive, broken links are skipped", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + + const old = join(home, "old.txt"); + await writeFile(old, "old", "utf8"); + const oldStaged = (await stageFile(home, old)).path; + await utimes( + oldStaged, + new Date(now - 2 * DAY_MS), + new Date(now - 2 * DAY_MS), + ); + + const fresh = join(home, "fresh.txt"); + await writeFile(fresh, "fresh", "utf8"); + const freshStaged = (await stageFile(home, fresh)).path; + + const keepDir = join(dir, "keep-me"); + await mkdir(keepDir, { recursive: true }); + await utimes( + keepDir, + new Date(now - 2 * DAY_MS), + new Date(now - 2 * DAY_MS), + ); + + await symlink( + join(dir, "target-that-does-not-exist"), + join(dir, "broken-link"), + ); + + await sweepSpool(home, now); + + expect(oldStaged.startsWith(dir)).toBe(true); + await expect( + (async () => { + const { stat } = await import("node:fs/promises"); + await stat(oldStaged); + })(), + ).rejects.toThrow(); + const { stat } = await import("node:fs/promises"); + expect((await stat(freshStaged)).isFile()).toBe(true); + expect((await stat(keepDir)).isDirectory()).toBe(true); + await rm(join(dir, "broken-link"), { force: true }); + }); +}); diff --git a/src/domain/file-transfer.ts b/src/domain/file-transfer.ts index 9228f2c..29f92a9 100644 --- a/src/domain/file-transfer.ts +++ b/src/domain/file-transfer.ts @@ -30,10 +30,9 @@ export function uploadsDir(homeDir: string, sessionId: string): string { return join(homeDir, ".claude", "uploads", sessionId); } -/** Sanitise a file name the way the reference staging path does. */ +/** Sanitise a file name the way the reference staging path does. A real basename is never empty, so no empty fallback is needed. */ function safeName(name: string): string { - const cleaned = name.replaceAll(/[^a-zA-Z0-9._-]/g, "_"); - return cleaned.length > 0 ? cleaned : "attachment"; + return name.replaceAll(/[^a-zA-Z0-9._-]/g, "_"); } /** Stage a file into the spool, returning the wire descriptor. */ @@ -105,7 +104,7 @@ export async function materialiseAttachment( ); await mkdir(dirname(dest), { recursive: true, mode: 0o700 }); await copyFile(absolute, dest); - await unlink(absolute).catch(() => undefined); + await unlink(absolute); return { uploadPath: dest, mention: `@"${dest}"` }; } @@ -141,7 +140,7 @@ export async function sweepSpool( const path = join(dir, name); const info = await stat(path).catch(() => undefined); if (info?.isFile() === true && info.mtimeMs < cutoff) { - await unlink(path).catch(() => undefined); + await unlink(path); } } } diff --git a/src/domain/hop-chain-edges.test.ts b/src/domain/hop-chain-edges.test.ts new file mode 100644 index 0000000..56e5234 --- /dev/null +++ b/src/domain/hop-chain-edges.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; +import { appendHop, checkChain } from "./hop-chain.js"; + +const id = (n: number) => n.toString(16).padStart(24, "0"); + +describe("hop-chain edge shapes", () => { + test("checkChain with no chain admits", () => { + expect(checkChain(undefined, new Set())).toEqual({ admitted: true }); + }); + + test("appendHop accepts an undefined chain", () => { + const own = id(1); + expect(appendHop(undefined, own)).toEqual([own]); + }); +}); diff --git a/src/domain/misc.test.ts b/src/domain/misc.test.ts new file mode 100644 index 0000000..0a3017f --- /dev/null +++ b/src/domain/misc.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { DEDUP_WINDOW_MS, varyBody } from "./dedup.js"; +import { newHopId, newMsgId } from "./ids.js"; +import { joinChain } from "./hop-chain.js"; + +describe("dedup", () => { + test("window is the receiver's 30s", () => { + expect(DEDUP_WINDOW_MS).toBe(30_000); + }); + + test("varyBody appends distinct invisible variation per nth", () => { + const body = "same text"; + const first = varyBody(body, 0); + const second = varyBody(body, 1); + expect(first).not.toBe(second); + expect(first.startsWith(body)).toBe(true); + expect(second.startsWith(body)).toBe(true); + expect(varyBody(body, 0)).toBe(first); + }); +}); + +describe("ids", () => { + test("msg ids are uuid v4 shaped", () => { + expect(newMsgId()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + test("hop ids are 24 lowercase hex", () => { + expect(newHopId()).toMatch(/^[0-9a-f]{24}$/); + }); +}); + +describe("joinChain", () => { + test("empty chains serialise to undefined", () => { + expect(joinChain([])).toBeUndefined(); + expect(joinChain(["a".repeat(24)])).toBe("a".repeat(24)); + }); +}); diff --git a/src/domain/units.test.ts b/src/domain/units.test.ts new file mode 100644 index 0000000..ea429b2 --- /dev/null +++ b/src/domain/units.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "vitest"; +import { Pacer } from "./pacer.js"; +import { filterRoster } from "./roster.js"; +import { assertRoundTrips } from "./envelope.js"; +import type { RegistryEntry } from "../schemas/registry.js"; +import type { ProcInfo } from "../ports/proc-info.js"; +import type { Transport } from "../ports/transport.js"; + +/** Sentinel: the lstart probe reports no start string at all. */ +const NO_START = "no-start"; + +class FakeClock { + constructor(private now: number) {} + nowMs(): number { + return this.now; + } + advance(ms: number): void { + this.now += ms; + } +} + +describe("Pacer", () => { + test("refills tokens over elapsed time and blocks until one is available", () => { + const clock = new FakeClock(1_000_000); + const pacer = new Pacer(clock, 2, 0.5); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + // Zero elapsed time must not refill. + expect(pacer.msUntilNextToken()).toBeGreaterThan(0); + // 0.5 tokens/s: a full deficit token takes 2s. + clock.advance(2_000); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + clock.advance(10_000); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + }); + + test("capacity caps the refill", () => { + const clock = new FakeClock(0); + const pacer = new Pacer(clock, 1, 1); + clock.advance(60_000); + expect(pacer.tryReserve()).toBe(true); + expect(pacer.tryReserve()).toBe(false); + }); + + test("msUntilNextToken is zero while tokens remain", () => { + const pacer = new Pacer(new FakeClock(0), 3, 0.5); + expect(pacer.msUntilNextToken()).toBe(0); + }); +}); + +function entry(overrides: Partial = {}): RegistryEntry { + return { + pid: 4242, + sessionId: "s", + cwd: "/", + startedAt: 1, + procStart: "start", + version: "v", + peerProtocol: 1, + peerFeatures: [], + kind: "interactive", + entrypoint: "cli", + pidDomain: "darwin", + messagingSocketPath: "/tmp/x.sock", + updatedAt: 1, + ...overrides, + }; +} + +function probes( + overrides: Readonly<{ + alive?: boolean; + /** NO_START means the probe reports no start string at all. */ + lstart?: string; + probe?: boolean; + ownSocketPath?: string; + }>, +): { + transport: Pick; + procInfo: ProcInfo; + ownSocketPath?: string; +} { + const resolveStart = (): string | undefined => + overrides.lstart === NO_START ? undefined : (overrides.lstart ?? "start"); + const lstart = async (): Promise => + Promise.resolve(resolveStart()); + const alive = async (): Promise => + Promise.resolve(overrides.alive ?? true); + const probe = async (): Promise => + Promise.resolve(overrides.probe ?? true); + return { + transport: { probe }, + procInfo: { alive, lstart }, + ...(overrides.ownSocketPath !== undefined + ? { ownSocketPath: overrides.ownSocketPath } + : {}), + }; +} + +describe("filterRoster verdicts", () => { + test("admits a fully live entry", async () => { + const result = await filterRoster([entry()], probes({})); + expect(result).toHaveLength(1); + }); + + test("excludes each documented failure reason", async () => { + const noSocket = await filterRoster( + [entry({ messagingSocketPath: "" })], + probes({}), + ); + expect(noSocket).toHaveLength(0); + + const own = await filterRoster( + [entry()], + probes({ ownSocketPath: "/tmp/x.sock" }), + ); + expect(own).toHaveLength(0); + + const deadPid = await filterRoster([entry()], probes({ alive: false })); + expect(deadPid).toHaveLength(0); + + const recycled = await filterRoster( + [entry()], + probes({ lstart: "different start" }), + ); + expect(recycled).toHaveLength(0); + + const missingStart = await filterRoster( + [entry()], + probes({ lstart: NO_START }), + ); + expect(missingStart).toHaveLength(0); + + const deadSocket = await filterRoster([entry()], probes({ probe: false })); + expect(deadSocket).toHaveLength(0); + }); +}); + +describe("assertRoundTrips", () => { + test("returns false for content that does not parse as an envelope", () => { + expect(assertRoundTrips("not an envelope")).toBe(false); + expect( + assertRoundTrips( + "no attrs", + ), + ).toBe(false); + }); +}); diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..56d7566 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { + CcPeerError, + TransportError, + NoLiveInboxError, + UnknownPeerError, + MessageTooLargeError, + UnvettedReplyTargetError, + NotStartedError, + ProtocolError, +} from "./errors.js"; + +describe("error taxonomy", () => { + test("every error carries its machine-readable code", () => { + const cases = [ + [new TransportError("t"), "TRANSPORT"], + [new NoLiveInboxError("n"), "NO_LIVE_INBOX"], + [new UnknownPeerError("u"), "UNKNOWN_PEER"], + [new MessageTooLargeError("m"), "MESSAGE_TOO_LARGE"], + [new UnvettedReplyTargetError("v"), "UNVETTED_REPLY_TARGET"], + [new NotStartedError("s"), "NOT_STARTED"], + [new ProtocolError("p"), "PROTOCOL"], + ] as const; + for (const [error, code] of cases) { + expect(error).toBeInstanceOf(CcPeerError); + expect(error.code).toBe(code); + expect(error.message).toBeTruthy(); + } + }); +}); diff --git a/src/schemas/guards.test.ts b/src/schemas/guards.test.ts new file mode 100644 index 0000000..c59539b --- /dev/null +++ b/src/schemas/guards.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "vitest"; +import { AuthLineSchema } from "./wire.js"; +import { PeerKeyFileSchema } from "./keyfile.js"; +import { RegistryEntrySchema } from "./registry.js"; +import { EnvelopeAddressSchema, EnvelopeAttributesSchema } from "./envelope.js"; +import { count } from "./limits.js"; +import { componentSchemasFrom, PeerTargetSchema } from "../api/schemas.js"; + +describe("attached type guards reject invalid shapes", () => { + test("auth line token must be 32 hex", () => { + expect(AuthLineSchema.is({ type: "auth", token: "short" })).toBe(false); + expect(AuthLineSchema.is({ type: "auth", token: "Z".repeat(32) })).toBe( + false, + ); + expect(AuthLineSchema.is({ type: "auth", token: "a".repeat(32) })).toBe( + true, + ); + }); + + test("key file token must be 32 hex", () => { + expect(PeerKeyFileSchema.is({ peerToken: "x", procStart: "s" })).toBe( + false, + ); + }); + + test("registry entry requires the full shape", () => { + expect(RegistryEntrySchema.is({})).toBe(false); + }); + + test("envelope address charset is enforced", () => { + expect(EnvelopeAddressSchema.is("has space!")).toBe(false); + expect(EnvelopeAddressSchema.is("uds:/tmp/x.sock")).toBe(true); + }); + + test("envelope attributes validate the grammar", () => { + expect(EnvelopeAttributesSchema.is({ from: "uds:/a.sock" })).toBe(true); + expect(EnvelopeAttributesSchema.is({ from: "" })).toBe(false); + expect( + EnvelopeAttributesSchema.is({ from: "a", hopChain: ["nothex"] }), + ).toBe(false); + expect(EnvelopeAttributesSchema.is({ from: "a", fromMode: "wizard" })).toBe( + false, + ); + }); +}); + +describe("peer target refine", () => { + test("at least one addressing field is required", () => { + expect(PeerTargetSchema.is({})).toBe(false); + expect(PeerTargetSchema.is({ pid: 1 })).toBe(true); + expect(PeerTargetSchema.is({ name: "bob" })).toBe(true); + expect(PeerTargetSchema.is({ address: "uds:/tmp/1.sock" })).toBe(true); + expect(PeerTargetSchema.is({ pid: 1, name: "bob" })).toBe(true); + }); +}); + +describe("componentSchemasFrom", () => { + test("rejects non-object conversions", () => { + expect(() => componentSchemasFrom(null)).toThrow( + "did not produce an object", + ); + expect(() => componentSchemasFrom([1, 2])).toThrow( + "did not produce an object", + ); + }); + + test("rejects a missing or non-object schemas member", () => { + expect(() => componentSchemasFrom({})).toThrow("no schemas object"); + expect(() => componentSchemasFrom({ schemas: 7 })).toThrow( + "no schemas object", + ); + }); + + test("rejects non-object component entries", () => { + expect(() => componentSchemasFrom({ schemas: { bad: 3 } })).toThrow( + "component bad is not a JSON object", + ); + }); + + test("strips the root-only $schema keyword from every component", () => { + const out = componentSchemasFrom({ + schemas: { + one: { $schema: "https://example.com/draft", type: "object" }, + two: { type: "string" }, + }, + }); + expect(Object.keys(out).sort()).toEqual(["one", "two"]); + expect(out.one).toEqual({ type: "object" }); + expect(out.two).toEqual({ type: "string" }); + }); +}); + +describe("count helper", () => { + test("stringifies numeric limits", () => { + expect(count(24)).toBe("24"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7a4ef81 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; + +// 100% thresholds: the test suite is the contract that every line and branch of src/ is exercised. Test files themselves are excluded from measurement. +export default defineConfig({ + test: { + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + // Entry glue (bin firing wrappers, the SEA entry) is exercised by the spawn-based smoke and SEA smoke tests, not by in-process unit coverage; ports are type-only declarations with no runtime statements to cover. + exclude: [ + "src/**/*.test.ts", + "src/test/**", + "src/bin/**", + "src/sea-entry.ts", + "src/ports/**", + ], + reporter: ["text", "html", "json-summary", "json"], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +}); From c00c250b131ce5be60f4112fd2b520f0fe58b990 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 04:58:35 +0100 Subject: [PATCH 02/10] docs(protocol): pin the queue-full cap finding to the served-config evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tengu_harbor_kite_limits dynamic override has never been served to this account — it is absent from the Statsig evaluations cache — so the effective peer-guard limits are the code defaults and maxQueuedPeerMessages is 50. The 55-messages-queued non-firing therefore reflects queue accounting (messages parked behind the approval dialog do not count toward the undelivered-peer-message queue) rather than a raised cap. --- docs/PROTOCOL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6467fa4..bb6e475 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -180,7 +180,7 @@ A receiving peer is the mirror: bind the socket, write key file and registry (pr | Holds, attestation, self-sent verdict, `crossSessionInbound` parity | verified live | | Peer registration, roster admission, name discovery | verified live (standalone peer in `ListAgents`, named `SendMessage`) | | Receipts: held / delivered / denied / expired / dropped{duplicate, rate-limited, hop-loop, hop-runaway} | verified live | -| `queue-full` | code-verified; trigger attempted (55 queued), effective cap dynamically raised | +| `queue-full` | code-verified; trigger attempted (55 queued behind a hold dialog, no drop). The `tengu_harbor_kite_limits` dynamic override has never been served to this account (absent from the Statsig evaluations cache), so the effective cap is the code default of 50 — the non-firing therefore reflects queue accounting (messages parked behind the approval dialog do not count toward the undelivered-peer-message queue), not a raised cap | | Idle subscriptions (`idle`, `exited`) | verified live | | artifact_yield admission + answer | verified live (refused and admitted paths); populated handover not exercised | | File transfer | staging + wire replicated; receive path behind a never-served server flag (evidenced) | From 7254eb3ad496a9ab903156a4d7e6212ac2a808a3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:02:32 +0100 Subject: [PATCH 03/10] test: close the last branch-coverage gaps in the rest facade Extract listeningPort and hostnameOf as pure, exported functions so their impossible-through-the-public-API sides are directly unit coverable rather than left as untested defensive branches. listeningPort throws when the underlying server ever reports a named-pipe address or null, which our own call site (always TCP host:port) cannot trigger, surfacing that violated assumption loudly instead of silently returning port 0. hostnameOf replaces a split()[0] fallback chain (whose second nullish coalescing was structurally dead, since split always returns a non-empty array) with indexOf/slice, so every branch is a real, constructible input rather than a type-only case. Added coverage for both, plus a POST /messages case exercising the priority and fromMode optional fields together, reaching 100 percent branches across the suite. Also drops a dead eslint-disable comment: the shared config's noInlineConfig makes inline disables inert, so the existing file-scoped rule override in eslint.config.ts was already the only thing suppressing the object-assign rule here. --- src/api/server.ts | 58 ++++++-- src/schemas/define-schema.ts | 5 +- test/api-extra.test.ts | 270 +++++++++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 test/api-extra.test.ts diff --git a/src/api/server.ts b/src/api/server.ts index 282448f..7c1744e 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { createServer } from "node:http"; import { randomBytes } from "node:crypto"; +import type { AddressInfo } from "node:net"; import type { CcPeer, PeerRef } from "../cc-peer.js"; import { @@ -49,9 +50,7 @@ export async function createApiServer( }); return new Promise((resolve) => { server.listen(options.port ?? 0, BIND_HOST, () => { - const address = server.address(); - const port = - typeof address === "object" && address !== null ? address.port : 0; + const port = listeningPort(server.address()); resolve({ port, token, @@ -82,7 +81,7 @@ async function handle( res.end(body); }; // DNS-rebinding defence: only localhost Host headers are served. - const host = (req.headers.host ?? "").split(":")[0] ?? ""; + const host = hostnameOf(req.headers.host); if (!ALLOWED_HOSTS.has(host)) { finish(HTTP_FORBIDDEN, errorBody("host not allowed")); return; @@ -94,10 +93,7 @@ async function handle( return; } } - const url = new URL( - req.url ?? "/", - `http://${req.headers.host ?? "localhost"}`, - ); + const url = requestUrl(req.url, req.headers.host); try { if (req.method === "GET" && url.pathname === "/healthz") { finish(HTTP_OK, JSON.stringify({ ok: true })); @@ -141,14 +137,52 @@ async function handle( } finish(HTTP_NOT_FOUND, errorBody("not found")); } catch (error) { - finish( - HTTP_INTERNAL, - errorBody(error instanceof Error ? error.message : "internal"), + finish(HTTP_INTERNAL, errorBody(httpErrorMessage(error))); + } +} + +/** + * Extract the numeric port `createApiServer` reports after `listen()`. The server always listens on a TCP host:port pair (never a named pipe), so `address()` returning anything but an AddressInfo object is a Node behaviour our own call site cannot trigger; a thrown error surfaces that violated assumption loudly rather than silently reporting port 0. Exported so the impossible-input side is directly unit-coverable without mocking node:net. + */ +export function listeningPort(address: string | AddressInfo | null): number { + if (address === null || typeof address !== "object") { + throw new Error( + "expected the server to report an AddressInfo after listen()", ); } + return address.port; } -function toPeerRef( +/** + * Extract the hostname portion of a Host header, ignoring any port suffix. Uses indexOf/slice rather than split()[0] so every branch is genuinely reachable: an absent header is a legitimate "reject as disallowed" case, and a header with no colon is the common case, both real inputs a test can construct directly, unlike split()[0]'s type-only undefined case. + */ +export function hostnameOf(hostHeader: string | undefined): string { + if (hostHeader === undefined) return ""; + const colonIndex = hostHeader.indexOf(":"); + return colonIndex === -1 ? hostHeader : hostHeader.slice(0, colonIndex); +} + +/** + * Build the request URL from possibly-absent raw parts. Exported as a pure function so both undefined sides of the fallbacks are directly coverable (Node always populates these for well-formed requests, but the types allow absence and malformed raw requests exercise it). + */ +export function requestUrl( + rawUrl: string | undefined, + host: string | undefined, +): URL { + return new URL(rawUrl ?? "/", `http://${host ?? "localhost"}`); +} + +/** + * Map a caught throwable to an HTTP error body message. Exported for direct unit coverage of the non-Error side, which live handlers cannot produce (every throw site raises Error subclasses). + */ +export function httpErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : JSON.stringify(error); +} + +/** + * Narrow an already schema-refined target to exactly one PeerRef shape. Exported for direct unit coverage of the no-field throw, which the PeerTargetSchema refine makes unreachable through the HTTP surface. + */ +export function toPeerRef( to: Readonly<{ pid?: number | undefined; name?: string | undefined; diff --git a/src/schemas/define-schema.ts b/src/schemas/define-schema.ts index 11f8850..577ede6 100644 --- a/src/schemas/define-schema.ts +++ b/src/schemas/define-schema.ts @@ -4,10 +4,7 @@ import type { z } from "zod"; * Attach an `.is()` type guard to a Zod schema so schema, inferred type, and runtime guard derive from one definition (single source of truth). `Schema.parse()` at JSON boundaries; `Schema.is()` for narrowing. */ export function defineSchema(schema: T) { - /* Object.assign is required over spread here: a Zod schema is a class - instance, and spreading it would drop the prototype (parse, safeParse, - refinements), so we deliberately attach the guard to the live instance. */ - // eslint-disable-next-line exadev/no-object-assign + // Object.assign is required over spread here: a Zod schema is a class instance, and spreading it would drop the prototype (parse, safeParse, refinements), so the guard is deliberately attached to the live instance. The no-object-assign rule is disabled for this file in eslint.config.ts. return Object.assign(schema, { is(value: unknown): value is z.infer { return schema.safeParse(value).success; diff --git a/test/api-extra.test.ts b/test/api-extra.test.ts new file mode 100644 index 0000000..81c9b72 --- /dev/null +++ b/test/api-extra.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "vitest"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + ReadableStreamDefaultReader, + ReadableStreamReadResult, +} from "node:stream/web"; + +import { CcPeer } from "../src/cc-peer.js"; +import { + createApiServer, + hostnameOf, + httpErrorMessage, + listeningPort, + requestUrl, + toPeerRef, +} from "../src/api/server.js"; + +async function tempHome(): Promise { + return mkdtemp(join(tmpdir(), "cc-peer-api2-")); +} + +async function peer(home: string, name: string, dir: string) { + return CcPeer.create({ + homeDir: home, + socketDir: join(home, dir), + name, + logger: () => { + void 0; + }, + }); +} + +describe("pure helpers", () => { + test("requestUrl falls back to / and localhost when raw parts are absent", () => { + const url = requestUrl(undefined, undefined); + expect(url.pathname).toBe("/"); + expect(url.hostname).toBe("localhost"); + }); + + test("requestUrl uses the given raw parts when present", () => { + const url = requestUrl("/sessions?x=1", "127.0.0.1:9000"); + expect(url.pathname).toBe("/sessions"); + expect(url.hostname).toBe("127.0.0.1"); + }); + + test("httpErrorMessage reads an Error's message and stringifies anything else", () => { + expect(httpErrorMessage(new Error("boom"))).toBe("boom"); + expect(httpErrorMessage("plain string")).toBe('"plain string"'); + expect(httpErrorMessage({ code: 7 })).toBe('{"code":7}'); + }); + + test("toPeerRef narrows by whichever field is present, pid taking priority", () => { + expect(toPeerRef({ pid: 1 })).toEqual({ pid: 1 }); + expect(toPeerRef({ pid: 1, name: "x" })).toEqual({ pid: 1 }); + expect(toPeerRef({ name: "x" })).toEqual({ name: "x" }); + expect(toPeerRef({ address: "uds:/a.sock" })).toEqual({ + address: "uds:/a.sock", + }); + expect(() => toPeerRef({})).toThrow("target must specify"); + }); + + test("listeningPort reads the port from an AddressInfo and rejects anything else", () => { + expect( + listeningPort({ address: "127.0.0.1", family: "IPv4", port: 4242 }), + ).toBe(4242); + expect(() => listeningPort(null)).toThrow( + "expected the server to report an AddressInfo", + ); + expect(() => listeningPort("/tmp/some.sock")).toThrow( + "expected the server to report an AddressInfo", + ); + }); + + test("hostnameOf strips a port suffix, passes through a bare host, and treats an absent header as empty", () => { + expect(hostnameOf("127.0.0.1:9000")).toBe("127.0.0.1"); + expect(hostnameOf("localhost")).toBe("localhost"); + expect(hostnameOf(undefined)).toBe(""); + }); +}); + +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 ?? ""}` }, + }); + 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("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, + }); + 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", + }, + 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(); + }); + + 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 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); + + 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 = ""; + + 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); + } + + 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); + + 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", + }), + }, + ); + expect(response.status).toBe(202); + await server.close(); + await sender.stop(); + await receiver.stop(); + }); +}); From 39cf03d2c81a2229f30936008f94e18290f078d1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:41:24 +0100 Subject: [PATCH 04/10] fix(mutation): pin vitest to 4.x and pass the config file explicitly to stryker Two independent bugs made the mutation gate meaningless: stryker's CLI only auto-discovers stryker.conf.json/.js/.mjs/.cjs, never .ts, so "stryker run" with no argument silently ran with command-line defaults (no thresholds, the whole src tree as the mutate scope) rather than this repository's own config; and @stryker-mutator/vitest-runner@10.0.0 joins nested test names in a format vitest 5 no longer produces, so a mutant run's testNamePattern regex matches nothing and every mutant executes zero tests, misreporting as Survived regardless of whether the real suite would actually kill it (confirmed directly: manually reproducing one mutant and running vitest against it directly failed the covering test exactly as expected, while stryker still reported it Survived with "Ran 0.00 tests per mutant"). Passing stryker.config.ts explicitly to the CLI fixes the first bug. For the second, no fixed vitest-runner release exists yet against vitest 5 as of this pin; downgrading vitest and its coverage provider to the 4.x line stryker-mutator/vitest-runner was actually built against removes the test-name mismatch, confirmed by the same mutant re-reporting Killed once real tests execute again. Both pins carry a documented reason per the dependency-pinning policy and should be revisited once an upstream fix ships. --- package.json | 6 +- pnpm-lock.yaml | 214 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 150 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index a183864..4072b0b 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "_typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:mutation": "stryker run", + "test:mutation": "stryker run stryker.config.ts", "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && publint && attw --pack", "prepare": "husky", "test:coverage": "vitest run --coverage" @@ -91,7 +91,7 @@ "@stryker-mutator/core": "10.0.0", "@stryker-mutator/vitest-runner": "10.0.0", "@types/node": "26.4.1", - "@vitest/coverage-v8": "5.0.0", + "@vitest/coverage-v8": "4.1.11", "commitlint": "21.2.2", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", @@ -107,6 +107,6 @@ "tsx": "4.23.13", "turbo": "2.10.12", "typescript": "6.0.3", - "vitest": "5.0.0" + "vitest": "4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 131d026..637f5cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,13 +57,13 @@ importers: version: 10.0.0(@types/node@26.4.1) '@stryker-mutator/vitest-runner': specifier: 10.0.0 - version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0) + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@4.1.11) '@types/node': specifier: 26.4.1 version: 26.4.1 '@vitest/coverage-v8': - specifier: 5.0.0 - version: 5.0.0(vitest@5.0.0) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) commitlint: specifier: 21.2.2 version: 21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) @@ -110,8 +110,8 @@ importers: specifier: 6.0.3 version: 6.0.3 vitest: - specifier: 5.0.0 - version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + specifier: 4.1.11 + version: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -1163,6 +1163,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stryker-mutator/api@10.0.0': resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} engines: {node: '>=22.0.0'} @@ -1339,25 +1342,20 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/coverage-v8@5.0.0': - resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 5.0.0 - vitest: 5.0.0 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/istanbul-lib-coverage@1.0.1': - resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} - engines: {node: '>=22'} - - '@vitest/istanbul-lib-report@1.0.1': - resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} - engines: {node: '>=22'} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@5.0.0': - resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1367,8 +1365,20 @@ packages: vite: optional: true - '@vitest/spy@5.0.0': - resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@yuku-codegen/binding-android-arm64@0.9.3': resolution: {integrity: sha512-viote6xAyL5cKLquV2X2wRfopSckH+msDYbaI8Hh8JAaogYs8MJZVRUbSrbsY29TaPrIFZwNRwQ8+YSxs0dkGw==} @@ -2215,6 +2225,9 @@ packages: html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@9.1.0: resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} engines: {node: '>= 20'} @@ -2342,6 +2355,18 @@ packages: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + java-properties@1.0.2: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} @@ -2537,8 +2562,8 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} - magic-string@1.2.3: - resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} @@ -2547,6 +2572,10 @@ packages: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked-terminal@7.3.0: resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} engines: {node: '>=16.0.0'} @@ -2862,6 +2891,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3217,13 +3249,8 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} - tinybench@6.1.4: - resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} - engines: {node: '>=20.0.0'} - - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} - engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} @@ -3468,23 +3495,23 @@ packages: yaml: optional: true - vitest@5.0.0: - resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} - engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 - '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.0 - '@vitest/browser-preview': 5.0.0 - '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.0 - '@vitest/coverage-v8': 5.0.0 - '@vitest/ui': 5.0.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' - vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -4619,6 +4646,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@stryker-mutator/api@10.0.0': dependencies: mutation-testing-metrics: 3.8.4 @@ -4676,14 +4705,14 @@ snapshots: '@stryker-mutator/util@10.0.0': {} - '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0)': + '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@4.1.11)': dependencies: '@stryker-mutator/api': 10.0.0 '@stryker-mutator/core': 10.0.0(@types/node@26.4.1) '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@turbo/darwin-64@2.10.12': optional: true @@ -4868,34 +4897,60 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/istanbul-lib-coverage': 1.0.1 - '@vitest/istanbul-lib-report': 1.0.1 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 magicast: 0.5.4 obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - - '@vitest/istanbul-lib-coverage@1.0.1': {} + vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - '@vitest/istanbul-lib-report@1.0.1': + '@vitest/expect@4.1.11': dependencies: - '@vitest/istanbul-lib-coverage': 1.0.1 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.0 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 - magic-string: 1.2.3 + magic-string: 0.30.21 optionalDependencies: vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) - '@vitest/spy@5.0.0': {} + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 '@yuku-codegen/binding-android-arm64@0.9.3': optional: true @@ -5705,6 +5760,8 @@ snapshots: html-entities@2.6.0: {} + html-escaper@2.0.2: {} + http-proxy-agent@9.1.0: dependencies: agent-base: 9.0.0 @@ -5805,6 +5862,19 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + java-properties@1.0.2: {} jiti@2.6.1: {} @@ -5954,7 +6024,7 @@ snapshots: lru-cache@11.5.2: {} - magic-string@1.2.3: + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5970,6 +6040,10 @@ snapshots: type-fest: 4.41.0 web-worker: 1.5.0 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + marked-terminal@7.3.0(marked@15.0.12): dependencies: ansi-escapes: 7.3.0 @@ -6190,6 +6264,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6580,9 +6656,7 @@ snapshots: dependencies: convert-hrtime: 5.0.0 - tinybench@6.1.4: {} - - tinyexec@1.3.0: {} + tinybench@2.9.0: {} tinyexec@1.3.1: {} @@ -6762,25 +6836,31 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: - '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - chai: 6.2.2 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.2.3 + magic-string: 0.30.21 obug: 2.1.4 + pathe: 2.0.3 picomatch: 4.0.7 std-env: 4.2.0 - tinybench: 6.1.4 - tinyexec: 1.3.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.1 - '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw From 41071c5fba2a45e83cb6631a1d3c6391549c658b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:52:00 +0100 Subject: [PATCH 05/10] test: kill real survivors in pacer, hop-chain, envelope, and the schema guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the vitest-runner/vitest version pin gives a trustworthy mutation score, work through the genuine survivors it surfaces: - pacer.ts: fold the refill guard's separate zero-elapsed branch into Math.max(0, elapsed) and the msUntilNextToken guard into Math.max(0, 1 - tokens), removing two comparisons that were only ever equivalent-in-effect at their own boundary (both branches produced byte-identical state there) with no test able to distinguish them; the simplified arithmetic keeps the same behaviour for every real input while shedding the unkillable comparison entirely. Added coverage for refill running via msUntilNextToken directly (not only after a preceding tryReserve), the exact fractional wait value, and a clock moving backward. - hop-chain.ts: multi-id joinChain output (the separator was untested with a single-element chain) and appendHop landing exactly at the grammar maximum without trimming. - envelope.ts: the exact escaped-body content (not just absence of the raw tag), a multi-entry hop-chain's comma separator in the built wire form, and a crafted body carrying an unescaped closing tag earlier in its own content — the greedy body capture runs to the LAST closing tag, so re-escaping on rebuild changes the string and assertRoundTrips must reject it, exactly the case the check exists to catch. Also asserts absent optional fields are omitted keys, not keys explicitly set to undefined, on both the from-only and from-less parses. - schemas/guards.test.ts: the key-file token test now supplies every required field (a missing pidDomain masked the token regex outcome entirely) with both a rejecting and an accepting token, and the envelope attributes test adds a fromSession charset boundary. - stryker.config.ts: ignoreStatic, with a documented, independently reproduced link to the still-open vitest-runner bug it works around (a module-load-time mutant that crashes test collection is misreported Survived rather than Killed). --- src/domain/envelope.test.ts | 29 +++++++++++++++++++++++++++++ src/domain/hop-chain.test.ts | 8 ++++++++ src/domain/misc.test.ts | 6 ++++++ src/domain/pacer.ts | 10 ++++++---- src/domain/units.test.ts | 24 ++++++++++++++++++++++++ src/schemas/guards.test.ts | 12 +++++++++++- stryker.config.ts | 2 ++ 7 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/domain/envelope.test.ts b/src/domain/envelope.test.ts index f60faac..e4b9e50 100644 --- a/src/domain/envelope.test.ts +++ b/src/domain/envelope.test.ts @@ -52,11 +52,33 @@ describe("buildEnvelope", () => { const body = "says then more"; const once = escapeBody(body); expect(once).not.toContain(""); + expect(once).toBe("says <\\> then more"); expect(escapeBody(once)).toBe(once); const env = buildEnvelope({ from: "uds:/tmp/x.sock" }, body); expect(assertRoundTrips(env)).toBe(true); }); + test("a hop chain of multiple ids serialises with comma separators", () => { + const env = buildEnvelope( + { + from: "uds:/tmp/x.sock", + hopChain: ["21cc6f3d5c60ce84a36b2054", "aaaaaaaaaaaaaaaaaaaaaaaa"], + }, + "x", + ); + expect(env).toContain( + 'hop-chain="21cc6f3d5c60ce84a36b2054,aaaaaaaaaaaaaaaaaaaaaaaa"', + ); + expect(assertRoundTrips(env)).toBe(true); + }); + + test("a body carrying an unescaped closing tag in the middle fails to round-trip", () => { + // The greedy body capture runs to the LAST closing tag in the content, so a raw, never-escaped closing tag earlier in the body is swallowed into the parsed body rather than terminating the match early. Rebuilding re-escapes that embedded tag, producing a different string than the untrusted original — exactly the case this check exists to reject. + const content = + '\nfirst part more text\n'; + expect(assertRoundTrips(content)).toBe(false); + }); + test("rejects non-canonical attribute order on parse", () => { const wrongOrder = '\nbody\n'; @@ -106,6 +128,11 @@ describe("buildEnvelope", () => { expect(parsed?.fromMode).toBeUndefined(); expect(parsed?.body).toBe("body only"); expect(assertRoundTrips(env)).toBe(true); + // Absent fields must be omitted keys, not keys explicitly set to undefined: only a genuinely omitted key is safe to spread into a downstream object without shadowing a real value there. + expect(parsed === undefined ? [] : Object.keys(parsed)).toEqual([ + "body", + "from", + ]); }); test("a from-less envelope parses but cannot round-trip", () => { @@ -114,5 +141,7 @@ describe("buildEnvelope", () => { expect(parsed?.from).toBeUndefined(); expect(parsed?.body).toBe("anonymous"); expect(assertRoundTrips(env)).toBe(false); + // "from" must be an omitted key here, not a key explicitly set to undefined, matching the same omit-vs-explicit-undefined contract as every other optional field. + expect(parsed === undefined ? [] : Object.keys(parsed)).toEqual(["body"]); }); }); diff --git a/src/domain/hop-chain.test.ts b/src/domain/hop-chain.test.ts index fd31dfe..479307a 100644 --- a/src/domain/hop-chain.test.ts +++ b/src/domain/hop-chain.test.ts @@ -26,6 +26,14 @@ describe("hop-chain", () => { expect(next[0]).toBe(id(2)); }); + test("appendHop does not trim when landing exactly at the grammar maximum", () => { + const chain = Array.from({ length: 31 }, (_, i) => id(i + 1)); + const next = appendHop(chain, ownToken); + expect(next).toHaveLength(32); + expect(next[0]).toBe(id(1)); + expect(next.at(-1)).toBe(ownToken); + }); + test("runaway fires above the guard length but not at it", () => { const at = Array.from({ length: MAX_CHAIN_LENGTH_GUARD }, (_, i) => id(i + 1), diff --git a/src/domain/misc.test.ts b/src/domain/misc.test.ts index 0a3017f..9517556 100644 --- a/src/domain/misc.test.ts +++ b/src/domain/misc.test.ts @@ -36,4 +36,10 @@ describe("joinChain", () => { expect(joinChain([])).toBeUndefined(); expect(joinChain(["a".repeat(24)])).toBe("a".repeat(24)); }); + + test("multiple ids join with a comma separator", () => { + expect(joinChain(["a".repeat(24), "b".repeat(24)])).toBe( + `${"a".repeat(24)},${"b".repeat(24)}`, + ); + }); }); diff --git a/src/domain/pacer.ts b/src/domain/pacer.ts index 701bec8..73dbe33 100644 --- a/src/domain/pacer.ts +++ b/src/domain/pacer.ts @@ -29,8 +29,7 @@ export class Pacer { /** Milliseconds to wait before one token is available (0 = now). */ msUntilNextToken(): number { this.refill(); - if (this.tokens >= 1) return 0; - const deficit = 1 - this.tokens; + const deficit = Math.max(0, 1 - this.tokens); return Math.ceil((deficit / this.refillPerSecond) * MS_PER_SECOND); } @@ -42,10 +41,13 @@ export class Pacer { return true; } + /** Clamps negative elapsed time (a clock moving backward) to zero rather than draining tokens, folding the guard into the arithmetic instead of a separate branch. */ private refill(): void { const now = this.clock.nowMs(); - const elapsedSeconds = (now - this.lastRefillMs) / MS_PER_SECOND; - if (elapsedSeconds <= 0) return; + const elapsedSeconds = Math.max( + 0, + (now - this.lastRefillMs) / MS_PER_SECOND, + ); this.tokens = Math.min( this.capacity, this.tokens + elapsedSeconds * this.refillPerSecond, diff --git a/src/domain/units.test.ts b/src/domain/units.test.ts index ea429b2..69da51e 100644 --- a/src/domain/units.test.ts +++ b/src/domain/units.test.ts @@ -50,6 +50,30 @@ describe("Pacer", () => { const pacer = new Pacer(new FakeClock(0), 3, 0.5); expect(pacer.msUntilNextToken()).toBe(0); }); + + test("msUntilNextToken refills from elapsed time on its own, without a prior tryReserve", () => { + const clock = new FakeClock(0); + const pacer = new Pacer(clock, 1, 1); + expect(pacer.tryReserve()).toBe(true); + clock.advance(1_000); + expect(pacer.msUntilNextToken()).toBe(0); + }); + + test("msUntilNextToken reports the exact wait for a fractional deficit", () => { + const clock = new FakeClock(0); + const pacer = new Pacer(clock, 1, 1); + expect(pacer.tryReserve()).toBe(true); + clock.advance(500); + expect(pacer.msUntilNextToken()).toBe(500); + }); + + test("refill ignores a clock that moves backward rather than draining tokens", () => { + const clock = new FakeClock(10_000); + const pacer = new Pacer(clock, 1, 1); + expect(pacer.tryReserve()).toBe(true); + clock.advance(-5_000); + expect(pacer.msUntilNextToken()).toBe(1_000); + }); }); function entry(overrides: Partial = {}): RegistryEntry { diff --git a/src/schemas/guards.test.ts b/src/schemas/guards.test.ts index c59539b..e99e3ab 100644 --- a/src/schemas/guards.test.ts +++ b/src/schemas/guards.test.ts @@ -18,9 +18,13 @@ describe("attached type guards reject invalid shapes", () => { }); test("key file token must be 32 hex", () => { - expect(PeerKeyFileSchema.is({ peerToken: "x", procStart: "s" })).toBe( + const shape = { procStart: "s", pidDomain: "darwin" }; + expect(PeerKeyFileSchema.is({ ...shape, peerToken: "x".repeat(32) })).toBe( false, ); + expect(PeerKeyFileSchema.is({ ...shape, peerToken: "a".repeat(32) })).toBe( + true, + ); }); test("registry entry requires the full shape", () => { @@ -41,6 +45,12 @@ describe("attached type guards reject invalid shapes", () => { expect(EnvelopeAttributesSchema.is({ from: "a", fromMode: "wizard" })).toBe( false, ); + expect( + EnvelopeAttributesSchema.is({ from: "a", fromSession: "has space" }), + ).toBe(false); + expect( + EnvelopeAttributesSchema.is({ from: "a", fromSession: "sess-1_2" }), + ).toBe(true); }); }); diff --git a/stryker.config.ts b/stryker.config.ts index 10e4c34..078d5b6 100644 --- a/stryker.config.ts +++ b/stryker.config.ts @@ -4,6 +4,8 @@ const options = { coverageAnalysis: "perTest", mutate: ["src/domain/**/*.ts", "src/schemas/**/*.ts", "!src/**/*.test.ts"], tempDirName: ".stryker-tmp", + // A module-load-time (static) mutant that breaks a whole file's import triggers a still-open @stryker-mutator/vitest-runner bug (stryker-mutator/stryker-js#6150): the runner's test-collection failure path never sets its own failure flag, so a mutant that crashes every test in a file at import time is misreported Survived instead of Killed. Confirmed directly: manually reproducing defineSchema's BlockStatement mutant broke ten test files at module load with a real vitest run, while stryker reported it Survived. ignoreStatic skips static mutants rather than trusting the runner's broken verdict on them; it can only raise a reported score, never hide a live bug, because a static mutant that is genuinely never exercised by any test was already unreachable through the covering-test analysis either way. + ignoreStatic: true, // 100% across the board: a surviving mutant is a behaviour the test suite does not pin. Mutation-exemption comments are banned by the lint rule in eslint.config.ts: a mutant that genuinely cannot be killed is a design smell to fix, not to exempt. thresholds: { high: 100, From a91e3de625a6b07eeac916489fd4e6f595b5fa30 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:55:04 +0100 Subject: [PATCH 06/10] refactor(roster): parallelise entry checks and export checkEntry for direct testing filterRoster awaited each entry's checkEntry call sequentially in a for-loop; roster() may probe dozens of live sessions per call and each check is an independent I/O round-trip with no shared state, so Promise.all runs them concurrently instead, a real latency win as the roster grows. checkEntry's own reason field was untestable from outside: filterRoster only returns the admitted entries, so a corrupted verdict object or a blanked-out reason string could never surface through the public return value even though it changed real behaviour internally. Exporting checkEntry lets a direct test assert the exact verdict shape and reason for every rejection path. Also drops two guards that were provably redundant given the schema's own guarantees: entry.messagingSocketPath is always a non-empty string by the point the own-socket check runs (the no-socket branch above it already returned otherwise), so comparing it against an absent ownSocketPath needs no separate undefined check; entry.procStart is always a defined, non-empty string, so an absent lstart already fails a plain inequality against it without a redundant explicit check. Both guards produced byte-identical results at every real input either way, adding a branch with no distinguishable behaviour to test against. --- src/domain/roster.ts | 20 +++++++-------- src/domain/units.test.ts | 53 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/domain/roster.ts b/src/domain/roster.ts index d6c5810..87786da 100644 --- a/src/domain/roster.ts +++ b/src/domain/roster.ts @@ -26,33 +26,31 @@ export async function filterRoster( entries: readonly RegistryEntry[], probes: RosterProbes, ): Promise { - const verdicts: RosterVerdict[] = []; - for (const entry of entries) { - const verdict = await checkEntry(entry, probes); - verdicts.push(verdict); - } + const verdicts = await Promise.all( + entries.map(async (entry) => checkEntry(entry, probes)), + ); return verdicts.filter((v) => v.admitted).map((v) => v.entry); } -async function checkEntry( +/** Exported for direct unit coverage of each rejection reason, which filterRoster's own filtered-entries return value cannot distinguish. */ +export async function checkEntry( entry: RegistryEntry, probes: RosterProbes, ): Promise { if (entry.messagingSocketPath.length === 0) { return { entry, admitted: false, reason: "no-socket" }; } - if ( - probes.ownSocketPath !== undefined && - entry.messagingSocketPath === probes.ownSocketPath - ) { + // messagingSocketPath is already known non-empty (the no-socket check above returned first otherwise), so comparing it against an absent ownSocketPath is always false without a separate undefined guard. + if (entry.messagingSocketPath === probes.ownSocketPath) { return { entry, admitted: false, reason: "own-socket" }; } const alive = await probes.procInfo.alive(entry.pid); if (!alive) { return { entry, admitted: false, reason: "pid-dead" }; } + // entry.procStart is always a defined, non-empty string (schema-enforced), so an absent lstart already satisfies "differs from procStart" on its own without a separate undefined check. const lstart = await probes.procInfo.lstart(entry.pid); - if (lstart === undefined || lstart !== entry.procStart) { + if (lstart !== entry.procStart) { return { entry, admitted: false, reason: "proc-start-mismatch" }; } const connectable = await probes.transport.probe(entry.messagingSocketPath); diff --git a/src/domain/units.test.ts b/src/domain/units.test.ts index 69da51e..a40621e 100644 --- a/src/domain/units.test.ts +++ b/src/domain/units.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { Pacer } from "./pacer.js"; -import { filterRoster } from "./roster.js"; +import { checkEntry, filterRoster } from "./roster.js"; import { assertRoundTrips } from "./envelope.js"; import type { RegistryEntry } from "../schemas/registry.js"; import type { ProcInfo } from "../ports/proc-info.js"; @@ -164,6 +164,57 @@ describe("filterRoster verdicts", () => { }); }); +describe("checkEntry reasons", () => { + test("each rejection carries its own specific reason and the rejected entry", async () => { + return Promise.all([ + checkEntry(entry({ messagingSocketPath: "" }), probes({})).then( + (verdict) => { + expect(verdict).toEqual({ + entry: entry({ messagingSocketPath: "" }), + admitted: false, + reason: "no-socket", + }); + }, + ), + checkEntry(entry(), probes({ ownSocketPath: "/tmp/x.sock" })).then( + (verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "own-socket", + }); + }, + ), + checkEntry(entry(), probes({ alive: false })).then((verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "pid-dead", + }); + }), + checkEntry(entry(), probes({ lstart: "different start" })).then( + (verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "proc-start-mismatch", + }); + }, + ), + checkEntry(entry(), probes({ probe: false })).then((verdict) => { + expect(verdict).toEqual({ + entry: entry(), + admitted: false, + reason: "socket-dead", + }); + }), + checkEntry(entry(), probes({})).then((verdict) => { + expect(verdict).toEqual({ entry: entry(), admitted: true }); + }), + ]); + }); +}); + describe("assertRoundTrips", () => { test("returns false for content that does not parse as an envelope", () => { expect(assertRoundTrips("not an envelope")).toBe(false); From 04566a8423446c37fa62138c6dc57b16187b10b8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:00:03 +0100 Subject: [PATCH 07/10] refactor(hop-chain): remove unused isHopId, joinChain, and parseChain None of the three had a production caller: parseEnvelope splits a captured hop-chain directly (the wire grammar's own regex already constrains each id to valid hex before the split ever runs, so parseChain's per-id re-validation was always redundant there), and serializeAttributes joins the array directly since its own emptiness check already precedes the call (joinChain's empty-to-undefined branch never had anywhere to matter). Neither cc-peer.ts nor the package's published exports referenced any of the three - only their own unit tests did, which this removes with them. Only appendHop and checkChain were ever actually exercised by the protocol implementation, and both already had real coverage against the same live-verified boundaries. appendHop's own trim also folds into a single Math.max/slice expression: slicing from a non-positive start is a no-op in JS, so a chain already at or under the maximum returns unchanged without a separate length comparison whose true and false branches produced an identical result at the exact boundary anyway (trimming to N elements when you already have exactly N is inherently a no-op, regardless of which branch decided to do it). --- src/domain/hop-chain.test.ts | 13 ------------- src/domain/hop-chain.ts | 27 +++------------------------ src/domain/misc.test.ts | 14 -------------- 3 files changed, 3 insertions(+), 51 deletions(-) diff --git a/src/domain/hop-chain.test.ts b/src/domain/hop-chain.test.ts index 479307a..b8745d9 100644 --- a/src/domain/hop-chain.test.ts +++ b/src/domain/hop-chain.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from "vitest"; import { appendHop, checkChain, - isHopId, - parseChain, MAX_CHAIN_LENGTH_GUARD, MAX_SELF_HOPS, } from "./hop-chain.js"; @@ -12,12 +10,6 @@ const id = (n: number) => n.toString(16).padStart(24, "0"); const ownToken = "21cc6f3d5c60ce84a36b2054"; describe("hop-chain", () => { - test("recognises 24-hex ids", () => { - expect(isHopId(ownToken)).toBe(true); - expect(isHopId("short")).toBe(false); - expect(isHopId("Z".repeat(24))).toBe(false); - }); - test("appendHop trims to the grammar maximum", () => { const chain = Array.from({ length: 32 }, (_, i) => id(i + 1)); const next = appendHop(chain, ownToken); @@ -59,9 +51,4 @@ describe("hop-chain", () => { test("a single own-token occurrence is harmless (verified protocol behaviour)", () => { expect(checkChain([ownToken], new Set([ownToken])).admitted).toBe(true); }); - - test("parseChain rejects malformed entries", () => { - expect(parseChain(`${ownToken},${ownToken}`)).toHaveLength(2); - expect(parseChain(`${ownToken},nothex`)).toBeUndefined(); - }); }); diff --git a/src/domain/hop-chain.ts b/src/domain/hop-chain.ts index fdc9d89..23cc6f3 100644 --- a/src/domain/hop-chain.ts +++ b/src/domain/hop-chain.ts @@ -1,40 +1,19 @@ -import { - count, - HOP_ID_HEX_LENGTH, - MAX_HOP_CHAIN_ENTRIES, -} from "../schemas/limits.js"; +import { MAX_HOP_CHAIN_ENTRIES } from "../schemas/limits.js"; /** Receiver-side guard default: chains longer than this drop as hop-runaway. */ export const MAX_CHAIN_LENGTH_GUARD = 28; /** Receiver-side guard default: this many own-token occurrences drop as hop-loop. */ export const MAX_SELF_HOPS = 10; -const HOP_ID_RE = new RegExp(`^[0-9a-f]{${count(HOP_ID_HEX_LENGTH)}}$`); - -export function isHopId(value: string): boolean { - return HOP_ID_RE.test(value); -} - -export function joinChain(ids: readonly string[]): string | undefined { - return ids.length > 0 ? ids.join(",") : undefined; -} - -export function parseChain(serialized: string): string[] | undefined { - const parts = serialized.split(","); - return parts.every((p) => HOP_ID_RE.test(p)) ? parts : undefined; -} - /** - * Append the relayer's own id, keeping at most the grammar-level maximum (the receiver trims to the same bound in `NDt`). + * Append the relayer's own id, keeping at most the grammar-level maximum (the receiver trims to the same bound in `NDt`). Always slicing (rather than branching on whether trimming is needed) means the maths alone decides the result: slicing from a non-positive start is a no-op in JS, so a chain already at or under the maximum is returned unchanged without a separate comparison to get right. */ export function appendHop( chain: readonly string[] | undefined, ownId: string, ): string[] { const next = [...(chain ?? []), ownId]; - return next.length > MAX_HOP_CHAIN_ENTRIES - ? next.slice(next.length - MAX_HOP_CHAIN_ENTRIES) - : next; + return next.slice(Math.max(0, next.length - MAX_HOP_CHAIN_ENTRIES)); } export interface ChainCheck { diff --git a/src/domain/misc.test.ts b/src/domain/misc.test.ts index 9517556..49a2e06 100644 --- a/src/domain/misc.test.ts +++ b/src/domain/misc.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "vitest"; import { DEDUP_WINDOW_MS, varyBody } from "./dedup.js"; import { newHopId, newMsgId } from "./ids.js"; -import { joinChain } from "./hop-chain.js"; describe("dedup", () => { test("window is the receiver's 30s", () => { @@ -30,16 +29,3 @@ describe("ids", () => { expect(newHopId()).toMatch(/^[0-9a-f]{24}$/); }); }); - -describe("joinChain", () => { - test("empty chains serialise to undefined", () => { - expect(joinChain([])).toBeUndefined(); - expect(joinChain(["a".repeat(24)])).toBe("a".repeat(24)); - }); - - test("multiple ids join with a comma separator", () => { - expect(joinChain(["a".repeat(24), "b".repeat(24)])).toBe( - `${"a".repeat(24)},${"b".repeat(24)}`, - ); - }); -}); From beadd2f6249ce0926f03b929164993028a6d7c96 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:04:27 +0100 Subject: [PATCH 08/10] test: kill real survivors in file-transfer Path helpers: exact spoolDir/uploadsDir segments, not just a substring check. stageFile: the boundary sits at exactly MAX_FILE_BYTES (accepts) versus one byte over (rejects), the staged filename's sha256 and uuid segments are each exactly 8 hex characters (an unsliced sha256 or uuid would break that exact shape), and the written file is owner-only (0600). materialiseAttachment: an attachment whose file_size alone is wrong (hash still matches the real bytes) fails integrity verification on its own, isolating that half of the OR from the sha256 check the existing test already covered; the delivered path uses the same 8-character prefix shape as staging. capAttachments: a batch of exactly the cap size passes through whole, the mirror case to the existing over-the-cap test. sweepSpool: an hour-old file survives against the real one-day cutoff (the multiply-vs-divide arithmetic mistake this guards would place the cutoff a fraction of a millisecond from "now", sweeping it up too); a file landing exactly on the cutoff survives, since only strictly-older files are removed; and a pass processing more entries than the sweep batch always leaves at least one untouched. --- src/domain/file-transfer-extra.test.ts | 129 +++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 9 deletions(-) diff --git a/src/domain/file-transfer-extra.test.ts b/src/domain/file-transfer-extra.test.ts index 49affb3..f6b3d06 100644 --- a/src/domain/file-transfer-extra.test.ts +++ b/src/domain/file-transfer-extra.test.ts @@ -5,11 +5,13 @@ import { mkdir, symlink, chmod, + stat, + readdir, utimes, rm, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { capAttachments, @@ -18,7 +20,9 @@ import { spoolDir, stageFile, sweepSpool, + uploadsDir, } from "./file-transfer.js"; +import { MAX_ATTACHMENTS_PER_MESSAGE } from "../schemas/limits.js"; import type { FileAttachment } from "../schemas/wire.js"; async function tempHome(): Promise { @@ -39,9 +43,23 @@ function attachment( }; } +describe("path helpers", () => { + test("spoolDir and uploadsDir use the exact reference segments", async () => { + const home = await tempHome(); + expect(spoolDir(home)).toBe(join(home, ".claude", "file-transfers")); + expect(uploadsDir(home, "sess-1")).toBe( + join(home, ".claude", "uploads", "sess-1"), + ); + }); +}); + describe("stageFile", () => { - test("rejects a file over the 30 MiB cap", async () => { + test("accepts a file at exactly the 30 MiB cap and rejects one byte over", async () => { const home = await tempHome(); + const atCap = join(home, "at-cap.bin"); + await writeFile(atCap, Buffer.alloc(MAX_FILE_BYTES, 7)); + await expect(stageFile(home, atCap)).resolves.toBeDefined(); + const big = join(home, "big.bin"); await writeFile(big, Buffer.alloc(MAX_FILE_BYTES + 1, 7)); await expect(stageFile(home, big)).rejects.toThrow("exceeds"); @@ -55,6 +73,18 @@ describe("stageFile", () => { expect(descriptor.file_name).toBe("héllo.txt"); expect(descriptor.path).toContain("h_llo.txt"); }); + + test("staged file names use 8-character sha256 and uuid prefixes, written owner-only", async () => { + const home = await tempHome(); + const source = join(home, "sized.txt"); + await writeFile(source, "prefix check content", "utf8"); + const descriptor = await stageFile(home, source); + 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"); + }); }); describe("materialiseAttachment refusals", () => { @@ -106,6 +136,31 @@ describe("materialiseAttachment refusals", () => { 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(); + const source = join(home, "sized-mismatch.txt"); + await writeFile(source, "exact content", "utf8"); + const descriptor = await stageFile(home, source); + const result = await materialiseAttachment(home, "s", { + ...descriptor, + file_size: descriptor.file_size + 1, + }); + expect(result).toContain("failed integrity verification"); + }); + + test("the delivered file lands under an 8-character sha256 and uuid prefixed name", async () => { + const home = await tempHome(); + const source = join(home, "deliver-me.txt"); + await writeFile(source, "deliver me", "utf8"); + const descriptor = await stageFile(home, source); + const result = await materialiseAttachment(home, "s", descriptor); + expect(typeof result).toBe("object"); + const uploadPath = typeof result === "object" ? result.uploadPath : ""; + expect(basename(uploadPath)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{8}-deliver-me\.txt$/, + ); + }); }); describe("capAttachments", () => { @@ -126,6 +181,15 @@ describe("capAttachments", () => { expect(kept).toHaveLength(16); expect(droppedNote).toContain("1 additional attachment"); }); + + test("a batch of exactly the cap size passes through whole with no note", () => { + const batch = Array.from({ length: MAX_ATTACHMENTS_PER_MESSAGE }, (_, i) => + attachment({ file_name: `f${i.toString()}.txt` }), + ); + const { kept, droppedNote } = capAttachments(batch); + expect(kept).toHaveLength(MAX_ATTACHMENTS_PER_MESSAGE); + expect(droppedNote).toBeUndefined(); + }); }); describe("sweepSpool", () => { @@ -169,15 +233,62 @@ describe("sweepSpool", () => { await sweepSpool(home, now); expect(oldStaged.startsWith(dir)).toBe(true); - await expect( - (async () => { - const { stat } = await import("node:fs/promises"); - await stat(oldStaged); - })(), - ).rejects.toThrow(); - const { stat } = await import("node:fs/promises"); + await expect(stat(oldStaged)).rejects.toThrow(); expect((await stat(freshStaged)).isFile()).toBe(true); expect((await stat(keepDir)).isDirectory()).toBe(true); await rm(join(dir, "broken-link"), { force: true }); }); + + test("the cutoff is the full one-day retention window, not a fraction of it", async () => { + // An hour-old file must survive against a real one-day cutoff; the arithmetic mistake this guards (dividing instead of multiplying) would push the cutoff to within a fraction of a millisecond of "now", which would incorrectly sweep this file up too. + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + const hourOld = join(home, "hour-old.txt"); + await writeFile(hourOld, "recent", "utf8"); + const staged = (await stageFile(home, hourOld)).path; + const oneHourMs = 3_600_000; + await utimes(staged, new Date(now - oneHourMs), new Date(now - oneHourMs)); + + await sweepSpool(home, now); + + expect((await stat(staged)).isFile()).toBe(true); + }); + + test("a file whose mtime lands exactly on the cutoff survives (strictly older only)", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + const onCutoff = join(home, "on-cutoff.txt"); + await writeFile(onCutoff, "boundary", "utf8"); + const staged = (await stageFile(home, onCutoff)).path; + const cutoff = now - DAY_MS; + await utimes(staged, new Date(cutoff), new Date(cutoff)); + + await sweepSpool(home, now); + + expect((await stat(staged)).isFile()).toBe(true); + }); + + test("a pass never removes more than the sweep batch limit", async () => { + const home = await tempHome(); + const dir = spoolDir(home); + await mkdir(dir, { recursive: true }); + const now = Date.now(); + const old = new Date(now - 2 * DAY_MS); + const SWEEP_BATCH = 200; + const total = SWEEP_BATCH + 1; + for (let i = 0; i < total; i += 1) { + const path = join(dir, `batch-${i.toString().padStart(4, "0")}.txt`); + await writeFile(path, "x", "utf8"); + await utimes(path, old, old); + } + + await sweepSpool(home, now); + + const remaining = await readdir(dir); + expect(remaining).toHaveLength(1); + }); }); From ed9f3e084401218db6efa607d20c445440ac503f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:15:30 +0100 Subject: [PATCH 09/10] test: kill real survivors across the wire and registry schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated wire.test.ts exercising every schema in wire.ts: one fully-shaped valid object per schema (kills every literal/enum-member mutation the fixture's own values happen to use, plus whole-object and nested-object ObjectLiteral mutations that would otherwise still validate an empty-required-field input), one deliberately incomplete object per schema, an out-of-charset from address for every schema carrying the address regex, and exhaustive membership checks for every enum (status, state, from_mode, drop reason). Length-boundary mutations (.min/.max swapped) fall out of using realistic, moderate-length fixture values throughout rather than single characters or the exact limit constants: a normal id or slug string fails both an inverted min() and an inverted max() in the direction that actually distinguishes them, so no separate boundary test was needed for most fields. Extract YieldReasonSchema as its own bare, uncaught enum: the object field wraps it in .catch("claim"), which recovers a corrupted "claim" member to exactly "claim" anyway, so testing the field's own parsed value can never tell a healthy enum from a broken one for that specific member. The bare schema's safeParse has no such fallback to mask a failure. guards.test.ts gains exhaustive .is() coverage for every registry enum (NameSource, PeerStatus, SessionKind, PeerFeature) alongside a rejected-value check for each. stryker.config.ts: replace the earlier ignoreStatic fix with a narrower exclusion of only define-schema.ts. ignoreStatic solved the one collection-crashing mutant it was added for, but every schema definition in this codebase is itself a top-level const, so it silently zeroed out mutation testing for the entire schemas directory — confirmed directly: wire.ts and registry.ts reported zero mutants under ignoreStatic despite having genuine, real survivors moments earlier. Excluding just the three-line helper keeps that side effect from reaching the schemas it don't apply to. --- src/schemas/guards.test.ts | 41 ++++- src/schemas/wire.test.ts | 323 +++++++++++++++++++++++++++++++++++++ src/schemas/wire.ts | 5 +- stryker.config.ts | 12 +- 4 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 src/schemas/wire.test.ts diff --git a/src/schemas/guards.test.ts b/src/schemas/guards.test.ts index e99e3ab..d76cffd 100644 --- a/src/schemas/guards.test.ts +++ b/src/schemas/guards.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "vitest"; import { AuthLineSchema } from "./wire.js"; import { PeerKeyFileSchema } from "./keyfile.js"; -import { RegistryEntrySchema } from "./registry.js"; +import { + NameSourceSchema, + PeerFeatureSchema, + PeerStatusSchema, + RegistryEntrySchema, + SessionKindSchema, +} from "./registry.js"; import { EnvelopeAddressSchema, EnvelopeAttributesSchema } from "./envelope.js"; import { count } from "./limits.js"; import { componentSchemasFrom, PeerTargetSchema } from "../api/schemas.js"; @@ -31,6 +37,39 @@ describe("attached type guards reject invalid shapes", () => { expect(RegistryEntrySchema.is({})).toBe(false); }); + test("every documented enum value is accepted by its own schema", () => { + for (const value of [ + "user", + "peer", + "derived", + "collision", + "auto", + "hook", + ]) { + expect(NameSourceSchema.is(value)).toBe(true); + } + expect(NameSourceSchema.is("nonsense")).toBe(false); + + for (const value of ["busy", "shell", "idle", "waiting"]) { + expect(PeerStatusSchema.is(value)).toBe(true); + } + expect(PeerStatusSchema.is("nonsense")).toBe(false); + + for (const value of ["interactive", "bg", "daemon", "daemon-worker"]) { + expect(SessionKindSchema.is(value)).toBe(true); + } + expect(SessionKindSchema.is("nonsense")).toBe(false); + + for (const value of [ + "notify_idle", + "reply_across_default_dirs", + "artifact_yield", + ]) { + expect(PeerFeatureSchema.is(value)).toBe(true); + } + expect(PeerFeatureSchema.is("nonsense")).toBe(false); + }); + test("envelope address charset is enforced", () => { expect(EnvelopeAddressSchema.is("has space!")).toBe(false); expect(EnvelopeAddressSchema.is("uds:/tmp/x.sock")).toBe(true); diff --git a/src/schemas/wire.test.ts b/src/schemas/wire.test.ts new file mode 100644 index 0000000..181c0f5 --- /dev/null +++ b/src/schemas/wire.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "vitest"; +import { + AuthLineSchema, + FileAttachmentSchema, + UserFrameSchema, + DropReasonSchema, + PeerMessageStatusSchema, + NotifyWhenIdleSchema, + PeerIdleNoticeSchema, + YieldArtifactRepliesSchema, + YieldReasonSchema, + ArtifactRepliesYieldedSchema, + UnyieldArtifactRepliesSchema, +} from "./wire.js"; + +const VALID_ATTACHMENT = { + path: "/tmp/staged-file.bin", + file_name: "report.pdf", + file_size: 4096, + sha256: "a".repeat(64), +}; + +describe("FileAttachmentSchema", () => { + test("accepts a fully-shaped attachment", () => { + expect(FileAttachmentSchema.is(VALID_ATTACHMENT)).toBe(true); + }); + + test("rejects a missing required field", () => { + expect(FileAttachmentSchema.is({})).toBe(false); + }); + + test("rejects a sha256 that does not match the hex-64 shape", () => { + expect( + FileAttachmentSchema.is({ ...VALID_ATTACHMENT, sha256: "not-hex" }), + ).toBe(false); + }); +}); + +const VALID_USER_FRAME = { + msgV: 1, + msg_id: "msg-outbound-1", + type: "user", + message: { role: "user", content: "hello there" }, + priority: "next", + from: "uds:/tmp/cc-socks/123.sock", +}; + +describe("UserFrameSchema", () => { + test("accepts a fully-shaped user frame", () => { + expect(UserFrameSchema.is(VALID_USER_FRAME)).toBe(true); + }); + + test("rejects a nested message missing its own required fields", () => { + expect(UserFrameSchema.is({ ...VALID_USER_FRAME, message: {} })).toBe( + false, + ); + }); + + test("rejects a from address containing a disallowed character", () => { + expect(UserFrameSchema.is({ ...VALID_USER_FRAME, from: "has space" })).toBe( + false, + ); + }); + + test("accepts a single file attachment, under the per-message cap", () => { + expect( + UserFrameSchema.is({ + ...VALID_USER_FRAME, + file_attachments: [VALID_ATTACHMENT], + }), + ).toBe(true); + }); +}); + +describe("DropReasonSchema", () => { + test("every documented drop reason is accepted", () => { + for (const reason of [ + "rate-limited", + "duplicate", + "hop-loop", + "hop-runaway", + "queue-full", + ]) { + expect(DropReasonSchema.is(reason)).toBe(true); + } + expect(DropReasonSchema.is("made-up-reason")).toBe(false); + }); +}); + +const VALID_PEER_MESSAGE_STATUS = { + type: "control", + action: "peer_message_status", + status: "delivered", + reason: "", + from: "uds:/tmp/cc-socks/123.sock", + orig_msg_id: "msg-outbound-1", + msgV: 1, + msg_id: "msg-status-1", +}; + +describe("PeerMessageStatusSchema", () => { + test("accepts a fully-shaped status frame", () => { + expect(PeerMessageStatusSchema.is(VALID_PEER_MESSAGE_STATUS)).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(PeerMessageStatusSchema.is({})).toBe(false); + }); + + test("every documented status value is accepted", () => { + for (const status of [ + "held", + "delivered", + "denied", + "expired", + "dropped", + ]) { + expect( + PeerMessageStatusSchema.is({ + ...VALID_PEER_MESSAGE_STATUS, + status, + }), + ).toBe(true); + } + }); + + test("rejects a from address containing a disallowed character", () => { + expect( + PeerMessageStatusSchema.is({ + ...VALID_PEER_MESSAGE_STATUS, + from: "has space", + }), + ).toBe(false); + }); +}); + +const VALID_NOTIFY_WHEN_IDLE = { + type: "control", + action: "notify_when_idle", + from: "uds:/tmp/cc-socks/123.sock", + msgV: 1, + msg_id: "msg-notify-1", +}; + +describe("NotifyWhenIdleSchema", () => { + test("accepts a fully-shaped frame", () => { + expect(NotifyWhenIdleSchema.is(VALID_NOTIFY_WHEN_IDLE)).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(NotifyWhenIdleSchema.is({})).toBe(false); + }); + + test("rejects a from address containing a disallowed character", () => { + expect( + NotifyWhenIdleSchema.is({ ...VALID_NOTIFY_WHEN_IDLE, from: "bad char" }), + ).toBe(false); + }); + + test("every documented from_mode value is accepted", () => { + for (const from_mode of ["bypass", "prompting"]) { + expect( + NotifyWhenIdleSchema.is({ ...VALID_NOTIFY_WHEN_IDLE, from_mode }), + ).toBe(true); + } + expect( + NotifyWhenIdleSchema.is({ + ...VALID_NOTIFY_WHEN_IDLE, + from_mode: "made-up", + }), + ).toBe(false); + }); +}); + +const VALID_PEER_IDLE_NOTICE = { + type: "control", + action: "peer_idle_notice", + orig_msg_id: "msg-outbound-1", + state: "idle", + finished_at: 1_726_000_000_000, + from: "uds:/tmp/cc-socks/123.sock", + msgV: 1, + msg_id: "msg-idle-1", +}; + +describe("PeerIdleNoticeSchema", () => { + test("accepts a fully-shaped frame", () => { + expect(PeerIdleNoticeSchema.is(VALID_PEER_IDLE_NOTICE)).toBe(true); + }); + + test("every documented state value is accepted", () => { + for (const state of ["idle", "exited"]) { + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, state }), + ).toBe(true); + } + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, state: "napping" }), + ).toBe(false); + }); + + test("rejects a from address containing a disallowed character", () => { + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, from: "bad char" }), + ).toBe(false); + }); + + test("every documented from_mode value is accepted", () => { + for (const from_mode of ["bypass", "prompting"]) { + expect( + PeerIdleNoticeSchema.is({ ...VALID_PEER_IDLE_NOTICE, from_mode }), + ).toBe(true); + } + expect( + PeerIdleNoticeSchema.is({ + ...VALID_PEER_IDLE_NOTICE, + from_mode: "made-up", + }), + ).toBe(false); + }); +}); + +const VALID_YIELD_ARTIFACT_REPLIES = { + type: "control", + action: "yield_artifact_replies", + from: "raw-socket-peer", + msg_id: "msg-yield-1", + session_id: "session-abc-123", + slugs: ["design-doc", "spec-outline"], + reason: "resume", + sent_at: 1_726_000_000_000, + msgV: 1, +}; + +describe("YieldArtifactRepliesSchema", () => { + test("accepts a fully-shaped frame", () => { + expect(YieldArtifactRepliesSchema.is(VALID_YIELD_ARTIFACT_REPLIES)).toBe( + true, + ); + }); + + test("rejects an incomplete object", () => { + expect(YieldArtifactRepliesSchema.is({})).toBe(false); + }); + + test("both documented reasons are genuine enum members, not just recoverable via the catch fallback", () => { + // Checking the field through YieldArtifactRepliesSchema can't distinguish a corrupted "claim" member from a healthy one: .catch("claim") means a rejected "claim" input recovers to exactly "claim" anyway, so the field-level result looks identical either way. YieldReasonSchema is the bare enum with no catch, so a genuinely corrupted member fails safeParse here instead of being silently repaired. + expect(YieldReasonSchema.safeParse("resume").success).toBe(true); + expect(YieldReasonSchema.safeParse("claim").success).toBe(true); + }); + + test("an unrecognised reason falls back to claim rather than failing validation", () => { + const parsed = YieldArtifactRepliesSchema.parse({ + ...VALID_YIELD_ARTIFACT_REPLIES, + reason: "made-up", + }); + expect(parsed.reason).toBe("claim"); + }); + + test("accepts a well-typed requester and rejects a wrongly-typed one", () => { + expect( + YieldArtifactRepliesSchema.is({ + ...VALID_YIELD_ARTIFACT_REPLIES, + requester: { cwd: "/home/user/project", tmux: "session-1" }, + }), + ).toBe(true); + expect( + YieldArtifactRepliesSchema.is({ + ...VALID_YIELD_ARTIFACT_REPLIES, + requester: { cwd: 12345 }, + }), + ).toBe(false); + }); +}); + +const VALID_ARTIFACT_REPLIES_YIELDED = { + type: "control", + action: "artifact_replies_yielded", + orig_msg_id: "msg-yield-1", + msgV: 1, + msg_id: "msg-yielded-1", +}; + +describe("ArtifactRepliesYieldedSchema", () => { + test("accepts a fully-shaped frame", () => { + expect( + ArtifactRepliesYieldedSchema.is(VALID_ARTIFACT_REPLIES_YIELDED), + ).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(ArtifactRepliesYieldedSchema.is({})).toBe(false); + }); +}); + +const VALID_UNYIELD_ARTIFACT_REPLIES = { + type: "control", + action: "unyield_artifact_replies", + orig_msg_id: "msg-yield-1", + slugs: ["design-doc", "spec-outline"], + msgV: 1, + msg_id: "msg-unyield-1", +}; + +describe("UnyieldArtifactRepliesSchema", () => { + test("accepts a fully-shaped frame", () => { + expect( + UnyieldArtifactRepliesSchema.is(VALID_UNYIELD_ARTIFACT_REPLIES), + ).toBe(true); + }); + + test("rejects an incomplete object", () => { + expect(UnyieldArtifactRepliesSchema.is({})).toBe(false); + }); +}); + +describe("AuthLineSchema companion coverage", () => { + test("accepts the documented auth shape", () => { + expect(AuthLineSchema.is({ type: "auth", token: "a".repeat(32) })).toBe( + true, + ); + }); +}); diff --git a/src/schemas/wire.ts b/src/schemas/wire.ts index 206b817..6e85238 100644 --- a/src/schemas/wire.ts +++ b/src/schemas/wire.ts @@ -134,6 +134,9 @@ export const PeerIdleNoticeSchema = defineSchema( ); export type PeerIdleNotice = z.infer; +/** Bare enum, exported separately so a test can assert its own membership directly rather than through the object field's .catch("claim") fallback, which would otherwise mask a corrupted "claim" member by coincidentally recovering the same value. */ +export const YieldReasonSchema = z.enum(["resume", "claim"]); + export const YieldArtifactRepliesSchema = defineSchema( z.object({ type: z.literal("control"), @@ -142,7 +145,7 @@ export const YieldArtifactRepliesSchema = defineSchema( msg_id: z.string().min(1).max(MAX_MSG_ID_CHARS), session_id: z.string().max(MAX_SESSION_ID_CHARS), slugs: z.array(z.string().max(MAX_SLUG_CHARS)).max(MAX_YIELD_SLUGS), - reason: z.enum(["resume", "claim"]).catch("claim"), + reason: YieldReasonSchema.catch("claim"), sent_at: z.number(), claimed_at: z.number().optional(), requester: z diff --git a/stryker.config.ts b/stryker.config.ts index 078d5b6..e3121d9 100644 --- a/stryker.config.ts +++ b/stryker.config.ts @@ -2,10 +2,16 @@ const options = { testRunner: "vitest", reporters: ["html", "clear-text", "progress"], coverageAnalysis: "perTest", - mutate: ["src/domain/**/*.ts", "src/schemas/**/*.ts", "!src/**/*.test.ts"], + // define-schema.ts is excluded, not just left to ignoreStatic: every schema module assigns defineSchema's return value straight to a top-level export const, so a mutant that empties defineSchema's body poisons every module that imports it and crashes test collection across ten-plus files at once. That triggers a still-open + // @stryker-mutator/vitest-runner bug (stryker-mutator/stryker-js#6150): + // the runner's collection-failure path never sets its own failure flag, so a mutant that crashes every test in a file at import time is misreported Survived instead of Killed, confirmed directly by reproducing it manually and watching a real vitest run correctly fail ten files while stryker still reported the mutant as surviving. ignoreStatic was tried first but is too broad a fix: every schema definition in this codebase is itself a top-level const, so it silently zeroes out mutation testing for the entire schemas directory, not just this one function. Excluding only define-schema.ts (a three-line helper already exercised indirectly by every other schema's own tests) keeps the rest of the schema layer's mutation score meaningful. + mutate: [ + "src/domain/**/*.ts", + "src/schemas/**/*.ts", + "!src/**/*.test.ts", + "!src/schemas/define-schema.ts", + ], tempDirName: ".stryker-tmp", - // A module-load-time (static) mutant that breaks a whole file's import triggers a still-open @stryker-mutator/vitest-runner bug (stryker-mutator/stryker-js#6150): the runner's test-collection failure path never sets its own failure flag, so a mutant that crashes every test in a file at import time is misreported Survived instead of Killed. Confirmed directly: manually reproducing defineSchema's BlockStatement mutant broke ten test files at module load with a real vitest run, while stryker reported it Survived. ignoreStatic skips static mutants rather than trusting the runner's broken verdict on them; it can only raise a reported score, never hide a live bug, because a static mutant that is genuinely never exercised by any test was already unreachable through the covering-test analysis either way. - ignoreStatic: true, // 100% across the board: a surviving mutant is a behaviour the test suite does not pin. Mutation-exemption comments are banned by the lint rule in eslint.config.ts: a mutant that genuinely cannot be killed is a design smell to fix, not to exempt. thresholds: { high: 100, From 16dc6b4772f54b56e91211cbc4849639b0e13501 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:19:13 +0100 Subject: [PATCH 10/10] fix(mutation): name the vitest-runner plugin explicitly CI's mutation gate failed on a clean checkout with "Cannot find TestRunner plugin vitest. In fact, no TestRunner plugins were loaded" - stryker's plugin auto-discovery didn't resolve @stryker-mutator/vitest-runner against a fresh pnpm install's strict, non-flat node_modules layout, even though the identical config ran fine locally against an already-populated node_modules. Naming the plugin explicitly in the plugins array removes the dependency on auto-discovery finding it either way. --- stryker.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stryker.config.ts b/stryker.config.ts index e3121d9..fe2076c 100644 --- a/stryker.config.ts +++ b/stryker.config.ts @@ -1,5 +1,9 @@ const options = { testRunner: "vitest", + // Named explicitly rather than left to auto-discovery: a fresh pnpm install's strict, non-flat node_modules layout does not expose + // @stryker-mutator/vitest-runner the way auto-discovery expects, failing + // with "Cannot find TestRunner plugin vitest" on a clean CI checkout even though the identical config resolves it fine against an already-populated local node_modules. + plugins: ["@stryker-mutator/vitest-runner"], reporters: ["html", "clear-text", "progress"], coverageAnalysis: "perTest", // define-schema.ts is excluded, not just left to ignoreStatic: every schema module assigns defineSchema's return value straight to a top-level export const, so a mutant that empties defineSchema's body poisons every module that imports it and crashes test collection across ten-plus files at once. That triggers a still-open