mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user