fix(cli): host fingerprint v2 — survive Mac restarts (1.34.17)
v1's sha256(host_id || mac) used the lex-first non-virtual interface's MAC — usually en0 on Wi-Fi Macs, whose MAC Apple's privacy feature re-randomizes across reboots. After a restart the recomputed hash no longer matched the stored one and the daemon entered a launchd respawn loop until manual `claudemesh daemon accept-host`. v2 reads IOPlatformUUID via ioreg on macOS (burned into EFI, stable), rejects locally-administered MACs in the picker, extends the ignored- interface list with anpi/bridge/ap[N], and prepends "v2\0" to the hash so v1 and v2 hashes can never collide on the same inputs. Migration is silent: a stored v1 fingerprint that still matches under the v1 algorithm is transparently rewritten as v2 with no error; v1 stores that fail v1 are reported as genuine mismatches as before; unknown future schema_versions return `unavailable` without overwriting. Drive-by fixes for two pre-existing test-infra papercuts found while validating: turbo's `test` task now depends on `build`, and a new vitest globalSetup rebuilds the CLI on demand with ~/.bun/bin and Homebrew layered into PATH — golden tests (whoami, --version) no longer fail opaquely after a clean checkout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,96 @@
|
||||
# Changelog
|
||||
|
||||
## 1.34.17 (2026-05-20) — host fingerprint v2: stable across Mac reboots
|
||||
|
||||
Fixes a long-standing class of false-positive `host_fingerprint
|
||||
mismatch` failures that put the daemon in a launchd restart loop
|
||||
after Mac reboots (one user observed the daemon's `runs` counter
|
||||
climb past 360 and `daemon.log` balloon to 24 MB before the next
|
||||
manual `claudemesh daemon accept-host`).
|
||||
|
||||
### Root cause
|
||||
|
||||
`apps/cli/src/daemon/identity.ts` (v1) computed `sha256(host_id ||
|
||||
mac)` where `host_id` was empty on macOS (the file commented "No
|
||||
reliable file; fall back to MAC-only fingerprint") and `mac` was
|
||||
picked by enumerating `os.networkInterfaces()`, filtering a small
|
||||
list of known-virtual interface name prefixes, then sorting the
|
||||
remainder lex and taking the first.
|
||||
|
||||
On a Mac with Wi-Fi the lex-first survivor was usually `en0` — but
|
||||
Apple's privacy feature randomizes the Wi-Fi MAC, and the random
|
||||
value can change across reboots, network rejoins, or when the user
|
||||
toggles "Private Wi-Fi address" for a network. The stored
|
||||
fingerprint was hashed from one randomized MAC; after a reboot,
|
||||
`pickStableMac()` returned a different randomized MAC; the hashes
|
||||
diverged; the daemon refused to start and the LaunchAgent
|
||||
respawned it every second.
|
||||
|
||||
The interface name filter was also missing several macOS virtual
|
||||
adapters (`anpi*`, `bridge*`, `ap[0-9]`) which can churn for
|
||||
similar reasons.
|
||||
|
||||
### The fix — schema_version 2
|
||||
|
||||
- **macOS host_id** — `IOPlatformUUID` via `ioreg -rd1 -c
|
||||
IOPlatformExpertDevice`, parsed at daemon start (~30 ms,
|
||||
cached for the process lifetime). Burned into EFI; stable
|
||||
across reboots, OS reinstalls, and macOS upgrades. Falls back
|
||||
to MAC-only if `ioreg` fails or is missing (`darwin` namespaced
|
||||
so it can never collide with a `linux:` machine-id).
|
||||
- **MAC picker hardening** — rejects any MAC with the
|
||||
locally-administered bit (`0x02` of the first byte) set. These
|
||||
are randomization-prone by definition (RFC 5342). Hardware
|
||||
MACs (universally-administered, OUI-prefixed) are preferred;
|
||||
locally-administered MACs are kept only as a fallback when no
|
||||
hardware NIC is enumerable.
|
||||
- **Extended interface ignore list** — `anpi`, `bridge`,
|
||||
`ap[0-9]` join the existing `lo|docker|br-|veth|tap|tun|
|
||||
tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw`.
|
||||
- **Domain-separated hash** — v2 hash prefixes `"v2\0"` before
|
||||
`host_id || \0 || mac`. Guarantees v1 and v2 outputs can never
|
||||
collide on the same inputs, so the migration check is
|
||||
unambiguous.
|
||||
|
||||
### Silent migration (v1 → v2)
|
||||
|
||||
`checkFingerprint()` now handles three cases:
|
||||
|
||||
1. Stored file has `schema_version: 2` → direct hash compare.
|
||||
2. Stored file has `schema_version: 1` → recompute fingerprint
|
||||
under the v1 algorithm. If it matches, the user is legitimately
|
||||
on the same host running v2 for the first time — the file is
|
||||
silently rewritten as v2 and the daemon proceeds. If the v1
|
||||
recompute also fails to match, this is a genuine mismatch
|
||||
(real host change, restored backup, accidental clone) and the
|
||||
daemon refuses as before.
|
||||
3. Stored file has an unknown future `schema_version` → treated
|
||||
as `unavailable` (not overwritten). Prevents a newer daemon
|
||||
from silently wiping a file it doesn't understand.
|
||||
|
||||
The v1 algorithm is preserved as a frozen internal helper for the
|
||||
migration path only. New code should never extend it.
|
||||
|
||||
### Tests + test infra fixes
|
||||
|
||||
`apps/cli/tests/unit/identity.test.ts` (18 cases) covers v2
|
||||
determinism, v2 domain separation from v1, hardware-MAC
|
||||
preference, locally-administered MAC fallback, every
|
||||
`checkFingerprint` branch, and the silent v1→v2 upgrade behavior
|
||||
of `acceptCurrentHost`.
|
||||
|
||||
Two pre-existing CI/test-infra papercuts surfaced while validating
|
||||
this fix and were corrected alongside:
|
||||
|
||||
- `apps/cli/tests/golden/{version,whoami}.test.ts` spawn the
|
||||
built CLI at `dist/entrypoints/cli.js` but nothing built it
|
||||
before `vitest run`. `turbo.json` now declares
|
||||
`test.dependsOn = ["build"]` so the monorepo always builds
|
||||
first, and a new vitest globalSetup
|
||||
(`apps/cli/tests/setup/ensure-built.ts`) rebuilds on demand
|
||||
with `~/.bun/bin` and Homebrew layered into PATH for the
|
||||
spawned subprocess.
|
||||
|
||||
## 1.34.15 (2026-05-04) — `peer list --mesh` actually scopes + `kick` refuses control-plane
|
||||
|
||||
Two follow-ups from the 1.34.x train, both backwards-compatible.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "1.34.16",
|
||||
"version": "1.34.17",
|
||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
|
||||
@@ -4,22 +4,37 @@
|
||||
//
|
||||
// NOT attacker-grade: anyone copying both the keypair AND the
|
||||
// host_fingerprint defeats this. Threat model §16 says so explicitly.
|
||||
//
|
||||
// ── schema_version: 2 (1.34.17+) ──
|
||||
// v1 was vulnerable to false mismatches across reboots on macOS because
|
||||
// `os.networkInterfaces()` returns Wi-Fi MACs that Apple's privacy
|
||||
// rotation re-randomizes (bit 0x02 of the first byte = "locally
|
||||
// administered"). After a Mac restart, en0's MAC could change → the
|
||||
// stored sha256(host_id || mac) no longer matched → the daemon refused
|
||||
// to start in a restart loop. Cure was always manual `accept-host`.
|
||||
//
|
||||
// v2 fixes the root cause: on macOS we read `IOPlatformUUID` via
|
||||
// `ioreg` (burned into EFI, never changes); the MAC picker rejects any
|
||||
// locally-administered MAC and prefers true hardware NICs. Migration
|
||||
// is silent — a v1 store that still matches the v1 algorithm is
|
||||
// rewritten transparently as v2 on first start under this version.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { networkInterfaces, type NetworkInterfaceInfo } from "node:os";
|
||||
|
||||
import { DAEMON_PATHS } from "./paths.js";
|
||||
|
||||
export type ClonePolicy = "refuse" | "warn" | "allow";
|
||||
|
||||
export interface FingerprintRecord {
|
||||
schema_version: 1;
|
||||
fingerprint: string; // sha256 hex
|
||||
host_id: string; // raw, for diagnostics
|
||||
stable_mac: string; // raw, for diagnostics
|
||||
written_at: string; // ISO date
|
||||
schema_version: 1 | 2;
|
||||
fingerprint: string; // sha256 hex
|
||||
host_id: string; // raw, for diagnostics
|
||||
stable_mac: string; // raw, for diagnostics
|
||||
written_at: string; // ISO date
|
||||
}
|
||||
|
||||
export interface FingerprintCheck {
|
||||
@@ -29,32 +44,27 @@ export interface FingerprintCheck {
|
||||
}
|
||||
|
||||
const FILE_NAME = "host_fingerprint.json";
|
||||
const CURRENT_SCHEMA: 2 = 2;
|
||||
|
||||
function path(): string { return join(DAEMON_PATHS.DAEMON_DIR, FILE_NAME); }
|
||||
function path(): string {
|
||||
return join(DAEMON_PATHS.DAEMON_DIR, FILE_NAME);
|
||||
}
|
||||
|
||||
/** Compute (without writing) the current host fingerprint. */
|
||||
// ── public API ────────────────────────────────────────────────────────
|
||||
|
||||
/** Compute (without writing) the current host fingerprint under v2 rules. */
|
||||
export function computeCurrentFingerprint(): FingerprintRecord {
|
||||
// Per spec §2.2 / followups doc: when neither host_id nor a stable MAC
|
||||
// are readable we fall back to a persisted random UUID. We DO NOT mint
|
||||
// a fresh random per call (that would make every restart look like a
|
||||
// clone). Instead, leave host_id empty when unknown — the MAC alone
|
||||
// identifies the host for accidental-clone detection.
|
||||
const host_id = readHostId() ?? "";
|
||||
const stable_mac = pickStableMac() ?? "";
|
||||
const fp = createHash("sha256").update(host_id, "utf8").update("\0").update(stable_mac, "utf8").digest("hex");
|
||||
const host_id = readHostIdV2() ?? "";
|
||||
const stable_mac = pickStableMacFromInterfaces(networkInterfaces()) ?? "";
|
||||
return {
|
||||
schema_version: 1,
|
||||
fingerprint: fp,
|
||||
schema_version: CURRENT_SCHEMA,
|
||||
fingerprint: fingerprintV2(host_id, stable_mac),
|
||||
host_id,
|
||||
stable_mac,
|
||||
written_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// `randomUUID` is no longer used after the random-fallback fix; keep the
|
||||
// import only if other helpers need it.
|
||||
void randomUUID;
|
||||
|
||||
/** Read or write the persisted fingerprint and report the result. */
|
||||
export function checkFingerprint(): FingerprintCheck {
|
||||
const current = computeCurrentFingerprint();
|
||||
@@ -63,47 +73,139 @@ export function checkFingerprint(): FingerprintCheck {
|
||||
return { result: "first_run", current };
|
||||
}
|
||||
let stored: FingerprintRecord;
|
||||
try { stored = JSON.parse(readFileSync(path(), "utf8")) as FingerprintRecord; }
|
||||
catch { return { result: "unavailable", current }; }
|
||||
if (stored.fingerprint === current.fingerprint) return { result: "match", current, stored };
|
||||
return { result: "mismatch", current, stored };
|
||||
try {
|
||||
stored = JSON.parse(readFileSync(path(), "utf8")) as FingerprintRecord;
|
||||
} catch {
|
||||
return { result: "unavailable", current };
|
||||
}
|
||||
|
||||
// v2 fast path: direct compare.
|
||||
if (stored.schema_version === 2) {
|
||||
if (stored.fingerprint === current.fingerprint)
|
||||
return { result: "match", current, stored };
|
||||
return { result: "mismatch", current, stored };
|
||||
}
|
||||
|
||||
// v1 migration path. Recompute under v1 rules; if it matches, the
|
||||
// user is legitimately on the same host, just running v2 for the
|
||||
// first time — rewrite the file as v2 and report match. If v1 does
|
||||
// not match either, this is a genuine mismatch (clone, restored
|
||||
// backup, or actual host change) and the daemon should refuse.
|
||||
if (stored.schema_version === 1) {
|
||||
const v1 = computeCurrentFingerprintV1();
|
||||
if (stored.fingerprint === v1.fingerprint) {
|
||||
writeFileSync(path(), JSON.stringify(current, null, 2), { mode: 0o600 });
|
||||
return { result: "match", current, stored };
|
||||
}
|
||||
return { result: "mismatch", current, stored };
|
||||
}
|
||||
|
||||
// Unknown future schema. Treat as unavailable rather than first_run
|
||||
// — we don't want a newer daemon to silently overwrite a file it
|
||||
// doesn't understand.
|
||||
return { result: "unavailable", current };
|
||||
}
|
||||
|
||||
/** Re-write the fingerprint file. Used by `daemon accept-host`. */
|
||||
/** Re-write the fingerprint file under v2 rules. Used by `daemon accept-host`. */
|
||||
export function acceptCurrentHost(): FingerprintRecord {
|
||||
const current = computeCurrentFingerprint();
|
||||
writeFileSync(path(), JSON.stringify(current, null, 2), { mode: 0o600 });
|
||||
return current;
|
||||
}
|
||||
|
||||
// ── platform helpers ───────────────────────────────────────────────────
|
||||
// ── v2 helpers (exported for tests) ───────────────────────────────────
|
||||
|
||||
function readHostId(): string | null {
|
||||
// Linux: /etc/machine-id (or /var/lib/dbus/machine-id).
|
||||
/** Pure: compute a v2 fingerprint from host_id + stable_mac strings. */
|
||||
export function fingerprintV2(host_id: string, stable_mac: string): string {
|
||||
// The "v2\0" prefix guarantees v1 and v2 hashes are domain-separated
|
||||
// even when fed identical inputs.
|
||||
return createHash("sha256")
|
||||
.update("v2", "utf8")
|
||||
.update("\0")
|
||||
.update(host_id, "utf8")
|
||||
.update("\0")
|
||||
.update(stable_mac, "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: pick a stable MAC from a NetworkInterfaceInfo map.
|
||||
* Rejects locally-administered MACs (bit 0x02 of first byte), which
|
||||
* are typically randomized on Apple Wi-Fi, NetworkManager privacy
|
||||
* mode, and most virtual bridges. Returns null if none qualify.
|
||||
*
|
||||
* Exported for unit tests that feed synthetic interface tables.
|
||||
*/
|
||||
export function pickStableMacFromInterfaces(
|
||||
ifs: NodeJS.Dict<NetworkInterfaceInfo[]>,
|
||||
): string | null {
|
||||
// First pass: prefer hardware (universally-administered) MACs.
|
||||
const hardware: Array<{ name: string; mac: string }> = [];
|
||||
const fallback: Array<{ name: string; mac: string }> = [];
|
||||
|
||||
for (const [name, addrs] of Object.entries(ifs)) {
|
||||
if (!addrs) continue;
|
||||
if (isIgnoredInterface(name)) continue;
|
||||
for (const a of addrs) {
|
||||
if (a.internal) continue;
|
||||
if (!a.mac || a.mac === "00:00:00:00:00:00") continue;
|
||||
const entry = { name, mac: a.mac };
|
||||
if (isLocallyAdministered(a.mac)) {
|
||||
fallback.push(entry);
|
||||
} else {
|
||||
hardware.push(entry);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const pool = hardware.length > 0 ? hardware : fallback;
|
||||
if (pool.length === 0) return null;
|
||||
pool.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return pool[0]!.mac;
|
||||
}
|
||||
|
||||
// ── v1 helpers (kept ONLY for migration; do not extend) ───────────────
|
||||
|
||||
function computeCurrentFingerprintV1(): FingerprintRecord {
|
||||
const host_id = readHostIdV1() ?? "";
|
||||
const stable_mac = pickStableMacV1() ?? "";
|
||||
const fp = createHash("sha256")
|
||||
.update(host_id, "utf8")
|
||||
.update("\0")
|
||||
.update(stable_mac, "utf8")
|
||||
.digest("hex");
|
||||
return {
|
||||
schema_version: 1,
|
||||
fingerprint: fp,
|
||||
host_id,
|
||||
stable_mac,
|
||||
written_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function readHostIdV1(): string | null {
|
||||
if (process.platform === "linux") {
|
||||
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
||||
try {
|
||||
const raw = readFileSync(p, "utf8").trim();
|
||||
if (raw) return `linux:${raw}`;
|
||||
} catch { /* try next */ }
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// macOS: IOPlatformUUID via ioreg. We avoid spawning by checking ENV.
|
||||
if (process.platform === "darwin") {
|
||||
// No reliable file; fall back to MAC-only fingerprint.
|
||||
return null;
|
||||
}
|
||||
// Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. Skip in v0.9.0.
|
||||
// v1 on macOS/Windows: empty host_id, MAC-only fingerprint.
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickStableMac(): string | null {
|
||||
function pickStableMacV1(): string | null {
|
||||
const ifs = networkInterfaces();
|
||||
const candidates: string[] = [];
|
||||
for (const [name, addrs] of Object.entries(ifs)) {
|
||||
if (!addrs) continue;
|
||||
if (isIgnoredInterface(name)) continue;
|
||||
if (isIgnoredInterfaceV1(name)) continue;
|
||||
for (const a of addrs) {
|
||||
if (a.internal) continue;
|
||||
if (!a.mac || a.mac === "00:00:00:00:00:00") continue;
|
||||
@@ -112,12 +214,98 @@ function pickStableMac(): string | null {
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort(); // lex by interface name
|
||||
candidates.sort();
|
||||
const first = candidates[0]!;
|
||||
const idx = first.indexOf("::");
|
||||
return idx >= 0 ? first.slice(idx + 2) : first;
|
||||
}
|
||||
|
||||
function isIgnoredInterface(name: string): boolean {
|
||||
return /^(lo|docker|br-|veth|tap|tun|tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw)/i.test(name);
|
||||
function isIgnoredInterfaceV1(name: string): boolean {
|
||||
return /^(lo|docker|br-|veth|tap|tun|tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw)/i.test(
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// ── platform helpers (v2) ─────────────────────────────────────────────
|
||||
|
||||
let cachedHostIdV2: string | null | undefined;
|
||||
|
||||
function readHostIdV2(): string | null {
|
||||
if (cachedHostIdV2 !== undefined) return cachedHostIdV2;
|
||||
cachedHostIdV2 = readHostIdV2Uncached();
|
||||
return cachedHostIdV2;
|
||||
}
|
||||
|
||||
function readHostIdV2Uncached(): string | null {
|
||||
// Linux: /etc/machine-id (or /var/lib/dbus/machine-id) — burned in at
|
||||
// first boot, stable across reboots, namespaced per spec.
|
||||
if (process.platform === "linux") {
|
||||
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
||||
try {
|
||||
const raw = readFileSync(p, "utf8").trim();
|
||||
if (raw) return `linux:${raw}`;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS: IOPlatformUUID is burned into EFI/hardware, stable across
|
||||
// reboots, OS reinstalls, and macOS upgrades. We spawn `ioreg` once
|
||||
// at daemon start (cached for process lifetime) — ~30 ms, run on
|
||||
// first start only thanks to the module-level cache.
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"/usr/sbin/ioreg",
|
||||
["-rd1", "-c", "IOPlatformExpertDevice"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
);
|
||||
const m = out.match(/"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]+)"/);
|
||||
if (m && m[1]) return `darwin:${m[1]}`;
|
||||
} catch {
|
||||
/* fall through to MAC-only fingerprint */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. Reading
|
||||
// the registry from Node would require a native module or a `reg
|
||||
// query` spawn — deferred until we have Windows users complaining.
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True if the MAC has the locally-administered bit set (0x02 of first byte). */
|
||||
function isLocallyAdministered(mac: string): boolean {
|
||||
const firstByte = parseInt(mac.split(":")[0] ?? "0", 16);
|
||||
if (Number.isNaN(firstByte)) return false;
|
||||
return (firstByte & 0x02) !== 0;
|
||||
}
|
||||
|
||||
function isIgnoredInterface(name: string): boolean {
|
||||
// Extends the v1 list with `anpi*` (Apple Network Personal Interface
|
||||
// — bridges to peripherals), `ap[0-9]` (AP mode adapters), and
|
||||
// `bridge` (virtual bridges from VMs / Internet Sharing). All can
|
||||
// appear with unstable MACs even when "hardware" by Node's standards.
|
||||
return /^(lo|docker|br-|bridge|veth|tap|tun|tailscale|wg|utun|ppp|vboxnet|vmnet|awdl|llw|anpi|ap\d)/i.test(
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// ── test-only hooks ───────────────────────────────────────────────────
|
||||
|
||||
/** Reset the module-level host_id cache. Used by unit tests only. */
|
||||
export function __resetHostIdCacheForTests(): void {
|
||||
cachedHostIdV2 = undefined;
|
||||
}
|
||||
|
||||
/** Compute the v1 fingerprint on this host. Tests use this to seed a
|
||||
* matching v1 fingerprint file to exercise the silent-migration path. */
|
||||
export function __computeV1FingerprintForTests(): FingerprintRecord {
|
||||
return computeCurrentFingerprintV1();
|
||||
}
|
||||
|
||||
71
apps/cli/tests/setup/ensure-built.ts
Normal file
71
apps/cli/tests/setup/ensure-built.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// vitest globalSetup — guarantees `dist/entrypoints/cli.js` exists
|
||||
// before any golden test spawns the built CLI. Without this, running
|
||||
// `npx vitest run` in a clean checkout (or after `pnpm run clean`)
|
||||
// surfaces as opaque `MODULE_NOT_FOUND` failures inside golden tests.
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join, dirname, delimiter } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_PKG_DIR = join(HERE, "..", "..");
|
||||
const CLI_ENTRY = join(CLI_PKG_DIR, "dist", "entrypoints", "cli.js");
|
||||
const BUILD_SCRIPT = join(CLI_PKG_DIR, "build.ts");
|
||||
const PKG_JSON = join(CLI_PKG_DIR, "package.json");
|
||||
|
||||
// Vitest's worker doesn't always inherit the user's shell PATH (no
|
||||
// `.zshrc`/`config.fish` is sourced), so a bun install at `~/.bun/bin`
|
||||
// is invisible to spawnSync. Layer the well-known install locations
|
||||
// in so the build command can find them.
|
||||
const EXTRA_PATHS = [
|
||||
join(homedir(), ".bun", "bin"),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
];
|
||||
|
||||
function augmentedEnv(): NodeJS.ProcessEnv {
|
||||
const current = process.env.PATH ?? "";
|
||||
const augmented = [...EXTRA_PATHS, current].filter(Boolean).join(delimiter);
|
||||
return { ...process.env, PATH: augmented };
|
||||
}
|
||||
|
||||
function isDistFresh(): boolean {
|
||||
if (!existsSync(CLI_ENTRY)) return false;
|
||||
// If the build script or package.json (which contributes the
|
||||
// injected version constant) is newer than dist, rebuild.
|
||||
try {
|
||||
const distMtime = statSync(CLI_ENTRY).mtimeMs;
|
||||
if (statSync(BUILD_SCRIPT).mtimeMs > distMtime) return false;
|
||||
if (statSync(PKG_JSON).mtimeMs > distMtime) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default async function setup(): Promise<void> {
|
||||
if (isDistFresh()) return;
|
||||
|
||||
// Try `bun build.ts` first (the canonical path). If bun is missing,
|
||||
// fall back to `pnpm run build` which delegates to the same script.
|
||||
const tries: Array<{ cmd: string; args: string[] }> = [
|
||||
{ cmd: "bun", args: ["build.ts"] },
|
||||
{ cmd: "pnpm", args: ["run", "build"] },
|
||||
];
|
||||
|
||||
const env = augmentedEnv();
|
||||
for (const { cmd, args } of tries) {
|
||||
const r = spawnSync(cmd, args, { cwd: CLI_PKG_DIR, stdio: "inherit", env });
|
||||
if (r.status === 0 && existsSync(CLI_ENTRY)) return;
|
||||
if (r.error && (r.error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`vitest globalSetup: failed to build the CLI. ` +
|
||||
`Tried \`bun build.ts\` and \`pnpm run build\`. ` +
|
||||
`Install bun (https://bun.sh) or run \`pnpm run build\` manually before testing.`,
|
||||
);
|
||||
}
|
||||
316
apps/cli/tests/unit/identity.test.ts
Normal file
316
apps/cli/tests/unit/identity.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { NetworkInterfaceInfo } from "node:os";
|
||||
|
||||
/**
|
||||
* identity.ts v2 — fingerprint algorithm + v1→v2 migration.
|
||||
*
|
||||
* `daemon/paths.ts` reads `CLAUDEMESH_DAEMON_DIR` ONCE at module load
|
||||
* and caches it in a `const`. We MUST set it at module load time of
|
||||
* this test file (BEFORE any `await import("~/daemon/identity.js")`
|
||||
* fires, which would transitively load paths.ts). Setting it inside
|
||||
* `beforeAll` is too late — the pure-helper describes above run their
|
||||
* dynamic imports first and pin paths.ts to the real `~/.claudemesh`.
|
||||
*/
|
||||
const TEST_DIR = join(
|
||||
tmpdir(),
|
||||
`claudemesh-identity-test-${Date.now()}-${process.pid}`,
|
||||
);
|
||||
process.env.CLAUDEMESH_DAEMON_DIR = TEST_DIR;
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
function iface(mac: string, internal = false): NetworkInterfaceInfo {
|
||||
return {
|
||||
address: "192.168.1.1",
|
||||
netmask: "255.255.255.0",
|
||||
family: "IPv4",
|
||||
mac,
|
||||
internal,
|
||||
cidr: "192.168.1.1/24",
|
||||
};
|
||||
}
|
||||
|
||||
describe("fingerprintV2 (pure)", () => {
|
||||
it("is deterministic for the same inputs", async () => {
|
||||
const { fingerprintV2 } = await import("~/daemon/identity.js");
|
||||
expect(fingerprintV2("darwin:ABC", "00:e0:4c:68:00:c0")).toBe(
|
||||
fingerprintV2("darwin:ABC", "00:e0:4c:68:00:c0"),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when host_id changes", async () => {
|
||||
const { fingerprintV2 } = await import("~/daemon/identity.js");
|
||||
expect(fingerprintV2("darwin:A", "00:e0:4c:68:00:c0")).not.toBe(
|
||||
fingerprintV2("darwin:B", "00:e0:4c:68:00:c0"),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when MAC changes", async () => {
|
||||
const { fingerprintV2 } = await import("~/daemon/identity.js");
|
||||
expect(fingerprintV2("darwin:A", "00:e0:4c:68:00:c0")).not.toBe(
|
||||
fingerprintV2("darwin:A", "00:e0:4c:68:00:c1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("is domain-separated from v1 — same inputs produce different hashes", async () => {
|
||||
const { fingerprintV2 } = await import("~/daemon/identity.js");
|
||||
const { createHash } = await import("node:crypto");
|
||||
// v1 was: sha256(host_id || \0 || mac). v2 prepends "v2\0".
|
||||
const v1Hash = createHash("sha256")
|
||||
.update("h", "utf8")
|
||||
.update("\0")
|
||||
.update("m", "utf8")
|
||||
.digest("hex");
|
||||
expect(fingerprintV2("h", "m")).not.toBe(v1Hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickStableMacFromInterfaces (pure)", () => {
|
||||
it("prefers a hardware (universally-administered) MAC over a locally-administered one", async () => {
|
||||
const { pickStableMacFromInterfaces } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const ifs = {
|
||||
en0: [iface("2a:11:99:2b:5f:c1")], // locally-admin (Apple Wi-Fi rotation)
|
||||
en7: [iface("00:e0:4c:68:00:c0")], // hardware (real NIC)
|
||||
};
|
||||
expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c0");
|
||||
});
|
||||
|
||||
it("falls back to a locally-administered MAC if no hardware MAC is available", async () => {
|
||||
const { pickStableMacFromInterfaces } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const ifs = {
|
||||
en0: [iface("2a:11:99:2b:5f:c1")],
|
||||
};
|
||||
expect(pickStableMacFromInterfaces(ifs)).toBe("2a:11:99:2b:5f:c1");
|
||||
});
|
||||
|
||||
it("ignores loopback, docker, tun/tap, tailscale, utun, awdl, llw, bridge, anpi, ap[0-9]", async () => {
|
||||
const { pickStableMacFromInterfaces } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const ifs = {
|
||||
lo0: [iface("00:00:00:00:00:00", true)],
|
||||
docker0: [iface("02:42:ac:11:00:01")],
|
||||
utun0: [iface("aa:bb:cc:dd:ee:ff")],
|
||||
awdl0: [iface("0e:df:dc:f9:da:33")],
|
||||
llw0: [iface("0e:df:dc:f9:da:33")],
|
||||
bridge0: [iface("36:77:b5:15:36:80")],
|
||||
anpi0: [iface("fe:f8:57:24:57:4a")],
|
||||
ap1: [iface("a2:e3:aa:60:12:88")],
|
||||
tailscale0: [iface("aa:bb:cc:11:22:33")],
|
||||
en0: [iface("00:e0:4c:68:00:c0")], // the only one that should win
|
||||
};
|
||||
expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c0");
|
||||
});
|
||||
|
||||
it("returns null when no interfaces qualify", async () => {
|
||||
const { pickStableMacFromInterfaces } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
expect(pickStableMacFromInterfaces({})).toBeNull();
|
||||
expect(
|
||||
pickStableMacFromInterfaces({
|
||||
lo0: [iface("00:00:00:00:00:00", true)],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("skips internal addresses and zero-MAC; picks the first valid by name", async () => {
|
||||
const { pickStableMacFromInterfaces } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const ifs = {
|
||||
en0: [iface("00:00:00:00:00:00")],
|
||||
en1: [iface("00:e0:4c:68:00:c0", true)],
|
||||
en2: [iface("00:e0:4c:68:00:c1")],
|
||||
};
|
||||
expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c1");
|
||||
});
|
||||
|
||||
it("sorts by interface name when multiple hardware MACs are present", async () => {
|
||||
const { pickStableMacFromInterfaces } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const ifs = {
|
||||
en1: [iface("00:e0:4c:68:00:c1")],
|
||||
en0: [iface("00:e0:4c:68:00:c0")],
|
||||
};
|
||||
expect(pickStableMacFromInterfaces(ifs)).toBe("00:e0:4c:68:00:c0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFingerprint (file-based)", () => {
|
||||
const testDir = TEST_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
for (const f of readdirSync(testDir)) {
|
||||
rmSync(join(testDir, f), { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.CLAUDEMESH_DAEMON_DIR;
|
||||
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("first_run writes a v2 fingerprint when no file exists", async () => {
|
||||
const { checkFingerprint } = await import("~/daemon/identity.js");
|
||||
const result = checkFingerprint();
|
||||
expect(result.result).toBe("first_run");
|
||||
expect(result.current.schema_version).toBe(2);
|
||||
const onDisk = JSON.parse(
|
||||
readFileSync(join(testDir, "host_fingerprint.json"), "utf8"),
|
||||
);
|
||||
expect(onDisk.schema_version).toBe(2);
|
||||
expect(onDisk.fingerprint).toBe(result.current.fingerprint);
|
||||
});
|
||||
|
||||
it("match returns 'match' when stored v2 fingerprint equals current", async () => {
|
||||
const { checkFingerprint, acceptCurrentHost } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const first = acceptCurrentHost();
|
||||
const result = checkFingerprint();
|
||||
expect(result.result).toBe("match");
|
||||
expect(result.current.fingerprint).toBe(first.fingerprint);
|
||||
});
|
||||
|
||||
it("v1 stored that matches v1 algorithm is silently upgraded to v2", async () => {
|
||||
const { checkFingerprint, __computeV1FingerprintForTests } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const v1 = __computeV1FingerprintForTests();
|
||||
writeFileSync(
|
||||
join(testDir, "host_fingerprint.json"),
|
||||
JSON.stringify(v1, null, 2),
|
||||
);
|
||||
|
||||
const result = checkFingerprint();
|
||||
|
||||
expect(result.result).toBe("match");
|
||||
expect(result.stored?.schema_version).toBe(1);
|
||||
expect(result.current.schema_version).toBe(2);
|
||||
|
||||
const after = JSON.parse(
|
||||
readFileSync(join(testDir, "host_fingerprint.json"), "utf8"),
|
||||
);
|
||||
expect(after.schema_version).toBe(2);
|
||||
expect(after.fingerprint).toBe(result.current.fingerprint);
|
||||
});
|
||||
|
||||
it("v1 stored that does NOT match v1 algorithm reports mismatch (genuine host change)", async () => {
|
||||
const { checkFingerprint } = await import("~/daemon/identity.js");
|
||||
writeFileSync(
|
||||
join(testDir, "host_fingerprint.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: 1,
|
||||
fingerprint: "0".repeat(64),
|
||||
host_id: "spoofed",
|
||||
stable_mac: "ff:ff:ff:ff:ff:ff",
|
||||
written_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const result = checkFingerprint();
|
||||
expect(result.result).toBe("mismatch");
|
||||
expect(result.stored?.schema_version).toBe(1);
|
||||
});
|
||||
|
||||
it("v2 stored with a different fingerprint reports mismatch", async () => {
|
||||
const { checkFingerprint, acceptCurrentHost } = await import(
|
||||
"~/daemon/identity.js"
|
||||
);
|
||||
const real = acceptCurrentHost();
|
||||
writeFileSync(
|
||||
join(testDir, "host_fingerprint.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
...real,
|
||||
fingerprint: "f".repeat(64),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const result = checkFingerprint();
|
||||
expect(result.result).toBe("mismatch");
|
||||
expect(result.stored?.schema_version).toBe(2);
|
||||
});
|
||||
|
||||
it("unknown future schema is treated as 'unavailable', not overwritten", async () => {
|
||||
const { checkFingerprint } = await import("~/daemon/identity.js");
|
||||
writeFileSync(
|
||||
join(testDir, "host_fingerprint.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: 99,
|
||||
fingerprint: "x",
|
||||
host_id: "x",
|
||||
stable_mac: "x",
|
||||
written_at: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const before = readFileSync(join(testDir, "host_fingerprint.json"), "utf8");
|
||||
const result = checkFingerprint();
|
||||
expect(result.result).toBe("unavailable");
|
||||
expect(readFileSync(join(testDir, "host_fingerprint.json"), "utf8")).toBe(
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
it("corrupt JSON is treated as 'unavailable', not overwritten", async () => {
|
||||
const { checkFingerprint } = await import("~/daemon/identity.js");
|
||||
writeFileSync(join(testDir, "host_fingerprint.json"), "{ not valid json");
|
||||
|
||||
const before = readFileSync(join(testDir, "host_fingerprint.json"), "utf8");
|
||||
const result = checkFingerprint();
|
||||
expect(result.result).toBe("unavailable");
|
||||
expect(readFileSync(join(testDir, "host_fingerprint.json"), "utf8")).toBe(
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptCurrentHost always writes a v2 fingerprint", async () => {
|
||||
const { acceptCurrentHost } = await import("~/daemon/identity.js");
|
||||
writeFileSync(
|
||||
join(testDir, "host_fingerprint.json"),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
fingerprint: "x",
|
||||
host_id: "",
|
||||
stable_mac: "",
|
||||
written_at: "",
|
||||
}),
|
||||
);
|
||||
const out = acceptCurrentHost();
|
||||
expect(out.schema_version).toBe(2);
|
||||
const onDisk = JSON.parse(
|
||||
readFileSync(join(testDir, "host_fingerprint.json"), "utf8"),
|
||||
);
|
||||
expect(onDisk.schema_version).toBe(2);
|
||||
expect(onDisk.fingerprint).toBe(out.fingerprint);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { resolve } from "node:path";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts", "src/**/*.test.ts"],
|
||||
// Builds the CLI on demand when `dist/entrypoints/cli.js` is
|
||||
// missing or older than its sources. Required by the golden
|
||||
// tests in `tests/golden/` which spawn the built artifact.
|
||||
globalSetup: ["tests/setup/ensure-built.ts"],
|
||||
alias: {
|
||||
"~": resolve(__dirname, "src"),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user