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>
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|