openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
476 lines (475 loc) • 19.7 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { a as getNodeSqliteKysely, i as executeSqliteQueryTakeFirstSync, r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js";
import { i as openOpenClawStateDatabase, s as runOpenClawStateWriteTransaction } from "./openclaw-state-db-BRTnL-D8.js";
import { d as readStoredDeviceIdentityReadOnly, f as repairInvalidStoredDeviceIdentity, h as acquireDeviceIdentityCoordinator, l as DeviceIdentityStorageError, m as validateStoredDeviceIdentity, p as resolveDeviceIdentityStore, u as generateStoredDeviceIdentity } from "./device-identity-J83pn_rP.js";
import { a as deriveEd25519PublicKeyRaw, i as deriveEd25519PrivateKeyRaw, o as ed25519PrivateKeyPemFromRaw, s as ed25519PublicKeyPemFromRaw, t as decodeCanonicalBase64OrBase64Url } from "./ed25519-signature-De1Kepnz.js";
import { t as pathMayExistSync } from "./path-existence-CzRtGGnO.js";
import { t as withLegacyMigrationStateLock } from "./state-migrations.lock-C0ywqz7v.js";
import { i as recordLegacyMigrationReceipt, n as readLegacyMigrationReceipt, r as readLegacyMigrationReceiptFromDatabase, s as resolveLegacyMigrationSourceKey, t as markLegacyMigrationSourceRemoved } from "./state-migrations.receipts-BIGn8ljb.js";
import { c as legacyMigrationSourceSnapshotsMatch, d as resolveLegacyMigrationRelativePath, l as readLegacyMigrationSourceSnapshot } from "./state-migrations.exec-approvals-IZ2-HGwX.js";
import path from "node:path";
import { createHash } from "node:crypto";
import { root } from "@openclaw/fs-safe";
//#region src/infra/device-identity-legacy.ts
function fingerprintPublicKey(publicKeyPem) {
return createHash("sha256").update(deriveEd25519PublicKeyRaw(publicKeyPem)).digest("hex");
}
function isValidCreatedAtMs$1(value) {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function normalizeLegacyCreatedAtMs(value) {
return isValidCreatedAtMs$1(value) ? value : Date.now();
}
function normalizeLegacyKeyPair(params) {
try {
const publicKeyRaw = deriveEd25519PublicKeyRaw(params.publicKeyPem);
const privateKeyRaw = deriveEd25519PrivateKeyRaw(params.privateKeyPem);
const publicKeyPem = ed25519PublicKeyPemFromRaw(publicKeyRaw);
const privateKeyPem = ed25519PrivateKeyPemFromRaw(privateKeyRaw);
const normalized = {
deviceId: fingerprintPublicKey(publicKeyPem),
publicKeyPem,
privateKeyPem,
createdAtMs: params.createdAtMs
};
validateStoredDeviceIdentity(normalized);
return normalized;
} catch {
return null;
}
}
/** Normalize a retired Node PEM or Swift raw-key payload for Doctor import. */
function normalizeLegacyDeviceIdentity(value) {
if (isRecord(value) && value.version === 1 && typeof value.deviceId === "string" && typeof value.publicKeyPem === "string" && typeof value.privateKeyPem === "string") return normalizeLegacyKeyPair({
createdAtMs: normalizeLegacyCreatedAtMs(value.createdAtMs),
privateKeyPem: value.privateKeyPem,
publicKeyPem: value.publicKeyPem
});
if (isRecord(value) && !("version" in value) && typeof value.deviceId === "string" && typeof value.publicKey === "string" && typeof value.privateKey === "string") try {
const publicKeyRaw = decodeCanonicalBase64OrBase64Url(value.publicKey);
const privateKeyRaw = decodeCanonicalBase64OrBase64Url(value.privateKey);
return normalizeLegacyKeyPair({
createdAtMs: normalizeLegacyCreatedAtMs(value.createdAtMs),
privateKeyPem: ed25519PrivateKeyPemFromRaw(privateKeyRaw),
publicKeyPem: ed25519PublicKeyPemFromRaw(publicKeyRaw)
});
} catch {
return null;
}
return null;
}
//#endregion
//#region src/infra/state-migrations.device-identity-repair.ts
const LEGACY_IDENTITY_RELATIVE_PATH = path.join("identity", "device.json");
const DOCTOR_CLAIM_SUFFIX = ".doctor-importing";
const NATIVE_CLAIM_SUFFIX = ".native-importing";
const IDENTITY_KEY$1 = "primary";
/** Detect retired paths for an authorized importer; only Doctor may rotate invalid SQLite state. */
function detectLegacyDeviceIdentity(params) {
const sourcePath = path.join(params.stateDir, LEGACY_IDENTITY_RELATIVE_PATH);
const claimPath = `${sourcePath}${DOCTOR_CLAIM_SUFFIX}`;
const nativeClaimPath = `${sourcePath}${NATIVE_CLAIM_SUFFIX}`;
const doctorAuthorized = params.doctorOnlyStateMigrations === true;
const importAuthorized = doctorAuthorized || params.allowLegacyDeviceIdentityImport === true;
let hasInvalidCanonical = false;
if (doctorAuthorized) try {
readStoredDeviceIdentityReadOnly({
env: {
...params.env ?? process.env,
OPENCLAW_STATE_DIR: params.stateDir
},
identityKey: IDENTITY_KEY$1
});
} catch (error) {
hasInvalidCanonical = error instanceof DeviceIdentityStorageError;
}
return {
sourcePath,
claimPath,
nativeClaimPath,
hasLegacy: importAuthorized && (pathMayExistSync(claimPath) || pathMayExistSync(nativeClaimPath) || pathMayExistSync(sourcePath)),
hasInvalidCanonical
};
}
function hasLegacyDeviceIdentityPath(detected) {
return pathMayExistSync(detected.claimPath) || pathMayExistSync(detected.nativeClaimPath) || pathMayExistSync(detected.sourcePath);
}
/** Generate a replacement only after the caller acquires Doctor's exclusive state lock. */
function repairInvalidCanonicalIdentity(env) {
try {
const result = repairInvalidStoredDeviceIdentity(generateStoredDeviceIdentity(), {
env,
identityKey: IDENTITY_KEY$1
});
if (!result.repaired) return {
changes: [],
warnings: []
};
if (!result.rotated) return {
changes: ["Repaired invalid primary device identity metadata in SQLite."],
warnings: []
};
return {
changes: ["Replaced invalid primary device identity in SQLite."],
warnings: [],
notices: ["The repaired device has a new identity and must be approved again."]
};
} catch (error) {
return {
changes: [],
warnings: [`Failed repairing invalid SQLite device identity: ${formatErrorMessage(error)}`]
};
}
}
//#endregion
//#region src/infra/state-migrations.device-identity.ts
const IDENTITY_KEY = "primary";
const MIGRATION_KIND = "legacy-device-identity-json";
const MAX_LEGACY_IDENTITY_BYTES = 131072;
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
function isValidCreatedAtMs(value) {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function deviceIdentityKeyMaterialMatches(left, right) {
try {
return deriveEd25519PublicKeyRaw(left.publicKeyPem).equals(deriveEd25519PublicKeyRaw(right.publicKeyPem)) && deriveEd25519PrivateKeyRaw(left.privateKeyPem).equals(deriveEd25519PrivateKeyRaw(right.privateKeyPem));
} catch {
return false;
}
}
function relativeLegacyPath(stateDir, filePath) {
return resolveLegacyMigrationRelativePath(stateDir, filePath, "device identity", false);
}
async function readLegacySourceSnapshot(params) {
const snapshot = await readLegacyMigrationSourceSnapshot({
...params,
maxBytes: MAX_LEGACY_IDENTITY_BYTES,
label: "device identity"
});
const identity = normalizeLegacyDeviceIdentity(JSON.parse(utf8Decoder.decode(snapshot.buffer)));
if (!identity) throw new Error("legacy device identity is invalid or unsupported");
return {
...snapshot,
identity
};
}
function classifyCanonicalRow(row, identity) {
if (!isValidCreatedAtMs(row.updated_at_ms)) return "invalid";
try {
validateStoredDeviceIdentity({
deviceId: row.device_id,
publicKeyPem: row.public_key_pem,
privateKeyPem: row.private_key_pem,
createdAtMs: row.created_at_ms
}, row.identity_key);
} catch {
return "invalid";
}
return row.identity_key === IDENTITY_KEY && row.device_id === identity.deviceId && deviceIdentityKeyMaterialMatches({
deviceId: row.device_id,
publicKeyPem: row.public_key_pem,
privateKeyPem: row.private_key_pem
}, identity) ? "same" : "different";
}
function readCanonicalIdentity(db) {
return executeSqliteQueryTakeFirstSync(db, getNodeSqliteKysely(db).selectFrom("device_identities").selectAll().where("identity_key", "=", IDENTITY_KEY));
}
function verifyCanonicalIdentity(identity, env) {
const { db } = openOpenClawStateDatabase({ env });
const row = readCanonicalIdentity(db);
if (!row || classifyCanonicalRow(row, identity) !== "same") throw new Error("canonical SQLite device identity no longer matches the legacy source");
}
function importAndRecordReceipt(params) {
const sourceKey = resolveLegacyMigrationSourceKey("device-identity-json", params.sourcePath);
const runId = `${sourceKey}:${params.snapshot.sha256.slice(0, 16)}`;
const now = Date.now();
return runOpenClawStateWriteTransaction(({ db }) => {
const stateDb = getNodeSqliteKysely(db);
const existingReceipt = readLegacyMigrationReceiptFromDatabase(db, sourceKey);
if (existingReceipt) {
if (existingReceipt.sourceSha256 !== params.snapshot.sha256) throw new Error("migration receipt belongs to different device identity bytes");
const existing = readCanonicalIdentity(db);
if (!existing || classifyCanonicalRow(existing, params.snapshot.identity) !== "same") throw new Error("migration receipt does not match the canonical device identity");
return {
sourceKey,
imported: false
};
}
const existing = readCanonicalIdentity(db);
const existingState = existing ? classifyCanonicalRow(existing, params.snapshot.identity) : void 0;
if (existingState === "different") throw new Error("canonical SQLite device identity differs from the legacy identity");
const imported = !existing || existingState === "invalid";
const repaired = existingState === "invalid";
if (!existing) executeSqliteQuerySync(db, stateDb.insertInto("device_identities").values({
identity_key: IDENTITY_KEY,
device_id: params.snapshot.identity.deviceId,
public_key_pem: params.snapshot.identity.publicKeyPem,
private_key_pem: params.snapshot.identity.privateKeyPem,
created_at_ms: params.snapshot.identity.createdAtMs,
updated_at_ms: now
}));
else if (repaired) executeSqliteQuerySync(db, stateDb.updateTable("device_identities").set({
device_id: params.snapshot.identity.deviceId,
public_key_pem: params.snapshot.identity.publicKeyPem,
private_key_pem: params.snapshot.identity.privateKeyPem,
created_at_ms: params.snapshot.identity.createdAtMs,
updated_at_ms: now
}).where("identity_key", "=", IDENTITY_KEY));
const verified = readCanonicalIdentity(db);
if (!verified || classifyCanonicalRow(verified, params.snapshot.identity) !== "same") throw new Error("SQLite verification failed for the primary device identity");
const reportJson = JSON.stringify({
source: MIGRATION_KIND,
target: "device_identities",
identityKey: IDENTITY_KEY,
deviceId: params.snapshot.identity.deviceId,
sourceSha256: params.snapshot.sha256,
importedRecordCount: imported ? 1 : 0,
preservedSqliteRecordCount: existing ? 1 : 0,
repairedSqliteRecordCount: repaired ? 1 : 0
});
recordLegacyMigrationReceipt(db, {
sourceKey,
migrationKind: MIGRATION_KIND,
sourcePath: params.sourcePath,
targetTable: "device_identities",
sourceSha256: params.snapshot.sha256,
sourceSizeBytes: params.snapshot.size,
sourceRecordCount: 1,
runId,
now,
reportJson
});
return {
sourceKey,
imported
};
}, { env: params.env });
}
async function removePath(params) {
if (params.removeSource) {
await params.removeSource(params.sourcePath);
return;
}
await params.stateRoot.remove(relativeLegacyPath(params.stateDir, params.sourcePath));
}
async function restoreClaim(params) {
try {
if (!await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.claimPath))) return null;
if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.sourcePath))) return `source path already exists: ${params.sourcePath}`;
await params.stateRoot.move(relativeLegacyPath(params.stateDir, params.claimPath), relativeLegacyPath(params.stateDir, params.sourcePath));
return null;
} catch (error) {
return String(error);
}
}
async function cleanupReceiptSources(params) {
if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.nativeClaimPath))) return {
changes: [],
warnings: ["Native device identity import is pending; restart the native app before running Doctor cleanup."]
};
const changes = [];
const warnings = [];
const notices = [];
let removed = 0;
for (const candidate of [params.detected.sourcePath, params.detected.claimPath]) {
if (!await params.stateRoot.exists(relativeLegacyPath(params.stateDir, candidate))) continue;
let snapshot;
try {
snapshot = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: candidate
});
} catch (error) {
warnings.push(`Retired device identity cleanup refused ${candidate}: ${String(error)}`);
continue;
}
if (snapshot.sha256 !== params.receipt.sourceSha256) {
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 {}
warnings.push(`Retired device identity cleanup preserved ${candidate}: bytes differ from the migration receipt.`);
continue;
}
try {
verifyCanonicalIdentity(snapshot.identity, params.env);
await removePath({
...params,
sourcePath: candidate
});
removed += 1;
} catch (error) {
warnings.push(`Retired device identity cleanup failed for ${candidate}: ${String(error)}`);
}
}
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,
notices
};
}
async function migrateWithExclusiveStateOwnership(params) {
const receipt = readLegacyMigrationReceipt(resolveLegacyMigrationSourceKey("device-identity-json", params.detected.sourcePath), params.env);
if (receipt) return await cleanupReceiptSources({
...params,
receipt
});
if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.nativeClaimPath))) return {
changes: [],
warnings: ["Native device identity import is pending; restart the native app before running Doctor."]
};
const hasSource = await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.sourcePath));
const hasClaim = await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.claimPath));
if (hasSource && hasClaim) return {
changes: [],
warnings: ["Failed migrating legacy device identity: source and interrupted claim both exist."]
};
const activePath = hasSource ? params.detected.sourcePath : hasClaim ? params.detected.claimPath : null;
if (!activePath) return {
changes: [],
warnings: []
};
let snapshot;
try {
snapshot = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: activePath
});
} catch (error) {
return {
changes: [],
warnings: [`Failed reading legacy device identity: ${String(error)}`]
};
}
if (activePath === params.detected.sourcePath) try {
params.beforeClaim?.(params.detected.sourcePath);
await params.stateRoot.move(relativeLegacyPath(params.stateDir, params.detected.sourcePath), relativeLegacyPath(params.stateDir, params.detected.claimPath));
const claimed = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: params.detected.claimPath
});
if (!legacyMigrationSourceSnapshotsMatch(snapshot, claimed)) throw new Error("legacy device identity changed before Doctor could claim it");
snapshot = claimed;
} catch (error) {
const restoreError = await restoreClaim({
...params,
...params.detected
});
return {
changes: [],
warnings: [`Failed migrating legacy device identity: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`]
};
}
let result;
try {
result = importAndRecordReceipt({
env: params.env,
sourcePath: params.detected.sourcePath,
snapshot
});
} catch (error) {
const restoreError = await restoreClaim({
...params,
...params.detected
});
return {
changes: [],
warnings: [`Failed migrating legacy device identity: ${String(error)}${restoreError ? `; restore failure: ${restoreError}` : ""}`]
};
}
try {
params.beforeCleanup?.();
if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.sourcePath))) throw new Error("legacy device identity source reappeared during import");
const finalSnapshot = await readLegacySourceSnapshot({
stateRoot: params.stateRoot,
stateDir: params.stateDir,
sourcePath: params.detected.claimPath
});
if (!legacyMigrationSourceSnapshotsMatch(snapshot, finalSnapshot)) throw new Error("legacy device identity claim changed after SQLite import");
verifyCanonicalIdentity(finalSnapshot.identity, params.env);
await removePath({
...params,
sourcePath: params.detected.claimPath
});
if (await params.stateRoot.exists(relativeLegacyPath(params.stateDir, params.detected.claimPath))) throw new Error("legacy device identity Doctor claim remains after cleanup");
markLegacyMigrationSourceRemoved(result.sourceKey, params.env);
} catch (error) {
return {
changes: [],
warnings: [`Device identity is in SQLite, but legacy cleanup failed: ${String(error)}`]
};
}
return {
changes: [result.imported ? "Migrated primary device identity to SQLite." : "Preserved identical primary device identity already in SQLite."],
warnings: [],
notices: ["Removed retired device identity JSON after verified SQLite import."]
};
}
/**
* Import a verified retired primary identity under explicit Doctor or startup authority.
* Startup authority cannot repair or replace an invalid canonical identity.
*/
async function migrateLegacyDeviceIdentity(params) {
if (!params.detected.hasLegacy && !params.detected.hasInvalidCanonical) return {
changes: [],
warnings: []
};
if (params.doctorOnlyStateMigrations !== true && params.allowLegacyDeviceIdentityImport !== true) return {
changes: [],
warnings: []
};
let identityCoordinator;
return await withLegacyMigrationStateLock({
stateDir: params.stateDir,
env: params.env,
label: "legacy device identity",
releaseLabel: "Device identity",
errorLabel: "Failed reading legacy device identity state",
beforeRelease: () => identityCoordinator?.release(),
run: async (env) => {
try {
identityCoordinator = acquireDeviceIdentityCoordinator({
databasePath: resolveDeviceIdentityStore({
env,
identityKey: IDENTITY_KEY
}).databasePath,
stateDir: params.stateDir
});
} catch (error) {
return {
changes: [],
warnings: [`Failed migrating legacy device identity: identity state is busy (${formatErrorMessage(error)}).`]
};
}
if (hasLegacyDeviceIdentityPath(params.detected)) {
const stateRoot = await root(params.stateDir, {
hardlinks: "reject",
maxBytes: MAX_LEGACY_IDENTITY_BYTES,
symlinks: "reject"
});
return await migrateWithExclusiveStateOwnership({
...params,
env,
stateRoot
});
}
return params.detected.hasInvalidCanonical ? repairInvalidCanonicalIdentity(env) : {
changes: [],
warnings: []
};
}
});
}
//#endregion
export { detectLegacyDeviceIdentity as n, migrateLegacyDeviceIdentity as t };