diff --git a/apps/cli/package.json b/apps/cli/package.json index 94c8275..8c10023 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.34.17", + "version": "1.34.18", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/daemon/identity.ts b/apps/cli/src/daemon/identity.ts index 905bd6b..e37d8be 100644 --- a/apps/cli/src/daemon/identity.ts +++ b/apps/cli/src/daemon/identity.ts @@ -83,6 +83,19 @@ export function checkFingerprint(): FingerprintCheck { if (stored.schema_version === 2) { if (stored.fingerprint === current.fingerprint) return { result: "match", current, stored }; + + // host_id wins: when stored and current both carry a non-empty + // host_id and they agree, the machine is the same — only the NIC + // topology shifted (dock unplugged, Wi-Fi privacy rotation, VPN + // adapter came online). host_id is hardware-rooted (IOPlatformUUID + // on macOS, /etc/machine-id on Linux) and is the load-bearing + // clone signal; stable_mac is best-effort defense-in-depth that + // legitimately drifts across boots. Silently rotate the stored + // record to the current MAC and proceed. + if (stored.host_id && stored.host_id === current.host_id) { + writeFileSync(path(), JSON.stringify(current, null, 2), { mode: 0o600 }); + return { result: "match", current, stored }; + } return { result: "mismatch", current, stored }; } diff --git a/apps/cli/tests/unit/identity.test.ts b/apps/cli/tests/unit/identity.test.ts index ba24abe..52ac9d5 100644 --- a/apps/cli/tests/unit/identity.test.ts +++ b/apps/cli/tests/unit/identity.test.ts @@ -234,7 +234,7 @@ describe("checkFingerprint (file-based)", () => { expect(result.stored?.schema_version).toBe(1); }); - it("v2 stored with a different fingerprint reports mismatch", async () => { + it("v2 stored with a different fingerprint AND different host_id reports mismatch (genuine clone)", async () => { const { checkFingerprint, acceptCurrentHost } = await import( "~/daemon/identity.js" ); @@ -245,6 +245,7 @@ describe("checkFingerprint (file-based)", () => { { ...real, fingerprint: "f".repeat(64), + host_id: real.host_id ? `${real.host_id}-cloned` : "linux:spoofed", }, null, 2, @@ -256,6 +257,66 @@ describe("checkFingerprint (file-based)", () => { expect(result.stored?.schema_version).toBe(2); }); + it("v2 stored with matching host_id but different stable_mac silently rotates to match (dock unplugged / Wi-Fi privacy rotation)", async () => { + const { checkFingerprint, acceptCurrentHost, fingerprintV2 } = await import( + "~/daemon/identity.js" + ); + const real = acceptCurrentHost(); + // Same host_id, different stable_mac → stale fingerprint on disk. + const staleMac = "00:e0:4c:99:99:99"; + const staleFingerprint = fingerprintV2(real.host_id, staleMac); + expect(staleFingerprint).not.toBe(real.fingerprint); + writeFileSync( + join(testDir, "host_fingerprint.json"), + JSON.stringify( + { + ...real, + stable_mac: staleMac, + fingerprint: staleFingerprint, + written_at: "2026-01-01T00:00:00.000Z", + }, + null, + 2, + ), + ); + + const result = checkFingerprint(); + expect(result.result).toBe("match"); + expect(result.stored?.schema_version).toBe(2); + + // Stored record is silently rewritten with the current MAC/fingerprint. + const onDisk = JSON.parse( + readFileSync(join(testDir, "host_fingerprint.json"), "utf8"), + ); + expect(onDisk.fingerprint).toBe(real.fingerprint); + expect(onDisk.stable_mac).toBe(real.stable_mac); + }); + + it("v2 stored with EMPTY host_id falls back to strict fingerprint compare (broken v1.34.16 record)", async () => { + // Records written by v1.34.16 had empty host_id on macOS — once + // current host_id starts populating correctly, we cannot use the + // host_id-wins branch (would silently rotate any clone). Strict + // fingerprint compare → mismatch → user runs accept-host. + const { checkFingerprint } = await import("~/daemon/identity.js"); + writeFileSync( + join(testDir, "host_fingerprint.json"), + JSON.stringify( + { + schema_version: 2, + fingerprint: "0".repeat(64), + host_id: "", + stable_mac: "00:e0:4c:11:22:33", + written_at: "2026-05-19T00:00:00.000Z", + }, + null, + 2, + ), + ); + + const result = checkFingerprint(); + expect(result.result).toBe("mismatch"); + }); + it("unknown future schema is treated as 'unavailable', not overwritten", async () => { const { checkFingerprint } = await import("~/daemon/identity.js"); writeFileSync(