fix(state): keep divergent retired device identity from blocking gateway readiness (#126748)

* fix(state): keep divergent retired device identity from blocking gateway readiness

Classify a divergent retired identity file as a startup notice when the canonical SQLite identity remains valid and authoritative under #120610. Preserve the fatal warning for missing or invalid canonical state, so the readiness gate itself remains unchanged.

Fixes #117270.

Release note: prevents gateway crash loops caused by a recreated retired device identity JSON after a verified SQLite migration.

* fix(state): preserve device identity migration receipt integrity

Reuse canonical SQLite identity validation and retain incomplete receipts for preserved divergent claims.

Co-authored-by: Vito Cappello <3279061+VACInc@users.noreply.github.com>

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vito Cappello
2026-08-21 20:47:11 -04:00
committed by GitHub
parent 89bb601b91
commit eee905934a
4 changed files with 103 additions and 4 deletions
@@ -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<MigrationDatabase>(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 () => {
+22 -2
View File
@@ -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: {
@@ -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);
@@ -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);
}