diff --git a/src/infra/state-migrations.device-identity.test.ts b/src/infra/state-migrations.device-identity.test.ts index e40059448868..e64118c8584a 100644 --- a/src/infra/state-migrations.device-identity.test.ts +++ b/src/infra/state-migrations.device-identity.test.ts @@ -708,7 +708,58 @@ describe("legacy device identity Doctor migration", () => { expect(receipt(env)).toMatchObject({ removed_source: 1 }); }); - it("does not discard recreated bytes that differ from the receipt", async () => { + it("preserves a divergent recreated identity as a boot-safe notice while the canonical row is valid", async () => { + const { env, stateDir } = useStateDir(); + const sourcePath = await writeLegacy({ stateDir }); + await migrate(stateDir, env, { + removeSource: () => { + throw new Error("simulated unlink failure"); + }, + }); + const divergent = anotherIdentity(); + const replacement = `${JSON.stringify({ + version: 1, + deviceId: divergent.deviceId, + publicKeyPem: divergent.publicKeyPem, + privateKeyPem: divergent.privateKeyPem, + createdAtMs: divergent.createdAtMs, + })}\n`; + await fsp.writeFile(sourcePath, replacement, "utf8"); + + closeOpenClawStateDatabaseForTest(); + const retry = await migrate(stateDir, env); + + // The startup readiness gate hard-fails on any migration warning, so this exact + // classification is what keeps a divergent inert file from crash-looping the gateway. + expect(retry.warnings).toEqual([]); + expect(retry.notices?.join("\n")).toContain("canonical SQLite identity remains authoritative"); + await expect(fsp.readFile(sourcePath, "utf8")).resolves.toBe(replacement); + expect(identityRow(env)?.created_at_ms).toBe(CREATED_AT_MS); + expect(receipt(env)).toMatchObject({ removed_source: 1 }); + }); + + it("does not mark a divergent preserved claim as removed", async () => { + const { env, stateDir } = useStateDir(); + const sourcePath = await writeLegacy({ stateDir }); + await migrate(stateDir, env, { + removeSource: () => { + throw new Error("simulated unlink failure"); + }, + }); + const claimPath = `${sourcePath}.doctor-importing`; + const replacement = `${JSON.stringify({ version: 1, ...anotherIdentity() })}\n`; + await fsp.writeFile(claimPath, replacement, "utf8"); + + closeOpenClawStateDatabaseForTest(); + const retry = await migrate(stateDir, env); + + expect(retry.warnings).toEqual([]); + expect(retry.notices?.join("\n")).toContain("canonical SQLite identity remains authoritative"); + await expect(fsp.readFile(claimPath, "utf8")).resolves.toBe(replacement); + expect(receipt(env)).toMatchObject({ removed_source: 0 }); + }); + + it("keeps the divergent-file warning fatal when the canonical row is invalid", async () => { const { env, stateDir } = useStateDir(); const sourcePath = await writeLegacy({ stateDir }); await migrate(stateDir, env, { @@ -718,13 +769,21 @@ describe("legacy device identity Doctor migration", () => { }); const replacement = `${JSON.stringify({ ...nodeIdentity(), createdAtMs: CREATED_AT_MS + 1 })}\n`; await fsp.writeFile(sourcePath, replacement, "utf8"); + const db = database(env); + executeSqliteQuerySync( + db, + getNodeSqliteKysely(db) + .updateTable("device_identities") + .set({ public_key_pem: "invalid-public-key", private_key_pem: "invalid-private-key" }) + .where("identity_key", "=", "primary"), + ); closeOpenClawStateDatabaseForTest(); const retry = await migrate(stateDir, env); expect(retry.warnings.join("\n")).toContain("bytes differ from the migration receipt"); + expect(retry.notices ?? []).toEqual([]); await expect(fsp.readFile(sourcePath, "utf8")).resolves.toBe(replacement); - expect(identityRow(env)?.created_at_ms).toBe(CREATED_AT_MS); }); it("rejects symlinked, hardlinked, oversized, non-UTF-8, and invalid sources", async () => { diff --git a/src/infra/state-migrations.device-identity.ts b/src/infra/state-migrations.device-identity.ts index 3a9b92b13442..6eaf463068a7 100644 --- a/src/infra/state-migrations.device-identity.ts +++ b/src/infra/state-migrations.device-identity.ts @@ -11,6 +11,7 @@ import { type NormalizedLegacyDeviceIdentity, } from "./device-identity-legacy.js"; import { + readStoredDeviceIdentityReadOnly, resolveDeviceIdentityStore, validateStoredDeviceIdentity, type DeviceIdentity, @@ -316,6 +317,7 @@ async function cleanupReceiptSources(params: { } const changes: string[] = []; const warnings: string[] = []; + const notices: string[] = []; let removed = 0; for (const candidate of [params.detected.sourcePath, params.detected.claimPath]) { if (!(await params.stateRoot.exists(relativeLegacyPath(params.stateDir, candidate)))) { @@ -333,6 +335,18 @@ async function cleanupReceiptSources(params: { continue; } if (snapshot.sha256 !== params.receipt.sourceSha256) { + // SQLite owns runtime identity; warning about inert retired bytes would + // make startup refuse an otherwise healthy gateway. + try { + if (readStoredDeviceIdentityReadOnly({ env: params.env, identityKey: IDENTITY_KEY })) { + notices.push( + `Preserved retired device identity ${candidate}: bytes differ from the migration receipt; the canonical SQLite identity remains authoritative. Archive or delete the file to clear this notice.`, + ); + continue; + } + } catch { + // Invalid canonical identity must retain its readiness-blocking warning. + } warnings.push( `Retired device identity cleanup preserved ${candidate}: bytes differ from the migration receipt.`, ); @@ -346,13 +360,19 @@ async function cleanupReceiptSources(params: { warnings.push(`Retired device identity cleanup failed for ${candidate}: ${String(error)}`); } } - if (warnings.length === 0 && (!params.receipt.removedSource || removed > 0)) { + // A divergent preserved claim cannot complete its interrupted receipt unless + // receipt-covered original bytes were actually removed during this pass. + if ( + warnings.length === 0 && + (!params.receipt.removedSource || removed > 0) && + (notices.length === 0 || removed > 0) + ) { markLegacyMigrationSourceRemoved(params.receipt.sourceKey, params.env); } if (removed > 0) { changes.push("Removed retired device identity JSON covered by its SQLite receipt."); } - return { changes, warnings }; + return { changes, warnings, notices }; } async function migrateWithExclusiveStateOwnership(params: { diff --git a/src/node-host/startup-state-migrations.test.ts b/src/node-host/startup-state-migrations.test.ts index 0ba978c3692a..a21ce1fc3d34 100644 --- a/src/node-host/startup-state-migrations.test.ts +++ b/src/node-host/startup-state-migrations.test.ts @@ -128,6 +128,23 @@ describe("node-host startup state migrations", () => { expect(log.warn).not.toHaveBeenCalled(); }); + it("reports a recreated divergent identity as a notice while preserving the canonical identity", async () => { + const { env, stateDir } = useStateDir(); + const { deviceId } = await writeDeviceIdentity(stateDir); + await runStartupMigrations({ env, log }); + vi.clearAllMocks(); + + const { sourcePath } = await writeDeviceIdentity(stateDir); + await runStartupMigrations({ env, log }); + + expect(fs.existsSync(sourcePath)).toBe(true); + expect(loadDeviceIdentityIfPresent({ env })?.deviceId).toBe(deviceId); + expect(log.info).toHaveBeenCalledWith( + expect.stringContaining("canonical SQLite identity remains authoritative"), + ); + expect(log.warn).not.toHaveBeenCalled(); + }); + it("preserves a pending native device identity claim and continues", async () => { const { env, stateDir } = useStateDir(); const { sourcePath } = await writeDeviceIdentity(stateDir); diff --git a/src/node-host/startup-state-migrations.ts b/src/node-host/startup-state-migrations.ts index ad52bd45c57c..376f9f7123bd 100644 --- a/src/node-host/startup-state-migrations.ts +++ b/src/node-host/startup-state-migrations.ts @@ -26,6 +26,9 @@ async function reportMigration( for (const change of result?.changes ?? []) { log.info(change); } + for (const notice of result?.notices ?? []) { + log.info(notice); + } for (const warning of result?.warnings ?? []) { log.warn(warning); }