fix(cli): host_id-wins fingerprint reconciliation (1.34.18)
When stored+current fingerprints both carry a matching non-empty host_id, treat a stable_mac drift (dock unplug, Wi-Fi privacy rotation, VPN adapter) as the same machine: silently rotate the stored MAC and proceed instead of flagging a clone. host_id (IOPlatformUUID / machine-id) is the load-bearing clone signal; stable_mac is best-effort and legitimately drifts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.34.17",
|
"version": "1.34.18",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ export function checkFingerprint(): FingerprintCheck {
|
|||||||
if (stored.schema_version === 2) {
|
if (stored.schema_version === 2) {
|
||||||
if (stored.fingerprint === current.fingerprint)
|
if (stored.fingerprint === current.fingerprint)
|
||||||
return { result: "match", current, stored };
|
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 };
|
return { result: "mismatch", current, stored };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe("checkFingerprint (file-based)", () => {
|
|||||||
expect(result.stored?.schema_version).toBe(1);
|
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(
|
const { checkFingerprint, acceptCurrentHost } = await import(
|
||||||
"~/daemon/identity.js"
|
"~/daemon/identity.js"
|
||||||
);
|
);
|
||||||
@@ -245,6 +245,7 @@ describe("checkFingerprint (file-based)", () => {
|
|||||||
{
|
{
|
||||||
...real,
|
...real,
|
||||||
fingerprint: "f".repeat(64),
|
fingerprint: "f".repeat(64),
|
||||||
|
host_id: real.host_id ? `${real.host_id}-cloned` : "linux:spoofed",
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -256,6 +257,66 @@ describe("checkFingerprint (file-based)", () => {
|
|||||||
expect(result.stored?.schema_version).toBe(2);
|
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 () => {
|
it("unknown future schema is treated as 'unavailable', not overwritten", async () => {
|
||||||
const { checkFingerprint } = await import("~/daemon/identity.js");
|
const { checkFingerprint } = await import("~/daemon/identity.js");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
Reference in New Issue
Block a user