diff --git a/src/infra/device-identity-store.ts b/src/infra/device-identity-store.ts index a6c9f86c413b..f774cf46d27f 100644 --- a/src/infra/device-identity-store.ts +++ b/src/infra/device-identity-store.ts @@ -15,6 +15,7 @@ import { deriveCanonicalEd25519PrivateKeyRaw, deriveCanonicalEd25519PublicKeyRaw, } from "./ed25519-signature.js"; +import { hasErrnoCode } from "./errno.js"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, @@ -40,6 +41,7 @@ export type DeviceIdentityStoreOptions = OpenClawStateDatabaseOptions & { type DeviceIdentityDatabase = Pick; type DeviceIdentityRow = Selectable; type DeviceIdentityInsert = Insertable; +type SqliteMasterDatabase = { sqlite_master: { name: string } }; export class DeviceIdentityStorageError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -246,6 +248,24 @@ function readStoredIdentityFromDatabase( return row ? rowToStoredIdentity(row, identityKey) : null; } +function isEmptyBootstrapIdentityTableMiss( + database: { db: Parameters[0] }, + error: unknown, +): boolean { + if ( + !(error instanceof Error) || + !hasErrnoCode(error, "ERR_SQLITE_ERROR") || + !/\bno such table: device_identities\b/iu.test(error.message) + ) { + return false; + } + const db = getNodeSqliteKysely(database.db); + return !executeSqliteQueryTakeFirstSync( + database.db, + db.selectFrom("sqlite_master").select("name").where("name", "not like", "sqlite_%").limit(1), + ); +} + /** Resolve the concrete database and row identity used by process caches and diagnostics. */ export function resolveDeviceIdentityStore(options: DeviceIdentityStoreOptions = {}): { databasePath: string; @@ -283,7 +303,17 @@ export function readStoredDeviceIdentityReadOnly( return ( withExistingOpenClawStateDatabaseArtifactPreservingReadOnly( (database) => { - const stored = readStoredIdentityFromDatabase(database, resolved.identityKey); + let stored: StoredDeviceIdentity | null; + try { + stored = readStoredIdentityFromDatabase(database, resolved.identityKey); + } catch (error) { + // A creator publishes the SQLite file before its schema transaction commits. + // Only that empty bootstrap snapshot is a read miss; partial schemas still fail closed. + if (isEmptyBootstrapIdentityTableMiss(database, error)) { + return null; + } + throw error; + } if (stored) { validateStoredDeviceIdentity(stored, resolved.identityKey); } diff --git a/src/infra/device-identity.test.ts b/src/infra/device-identity.test.ts index 711ff84478ec..632f269e87b9 100644 --- a/src/infra/device-identity.test.ts +++ b/src/infra/device-identity.test.ts @@ -147,6 +147,98 @@ async function runConcurrentIdentityLoads(rootDir: string): Promise; + child: ChildProcess; +}> { + const databasePath = path.join(rootDir, "state", "openclaw.sqlite"); + const readyPath = path.join(rootDir, "bootstrap-ready"); + const committedPath = path.join(rootDir, "bootstrap-committed"); + const continuePath = path.join(rootDir, "bootstrap-continue"); + const coordinatorModuleUrl = new URL("./device-identity-coordinator.ts", import.meta.url).href; + const storeModuleUrl = new URL("./device-identity-store.ts", import.meta.url).href; + const workerSource = ` + import fs from "node:fs"; + import path from "node:path"; + import { DatabaseSync } from "node:sqlite"; + const { acquireDeviceIdentityCoordinator } = await import(process.env.OPENCLAW_COORDINATOR_MODULE); + const { generateStoredDeviceIdentity, insertStoredDeviceIdentityIfAbsent } = + await import(process.env.OPENCLAW_IDENTITY_STORE_MODULE); + const options = { + env: { ...process.env, OPENCLAW_STATE_DIR: process.env.OPENCLAW_IDENTITY_STATE_DIR }, + path: process.env.OPENCLAW_IDENTITY_DATABASE_PATH, + }; + const coordinator = acquireDeviceIdentityCoordinator({ + databasePath: options.path, + stateDir: process.env.OPENCLAW_IDENTITY_STATE_DIR, + }); + try { + fs.mkdirSync(path.dirname(options.path), { recursive: true }); + new DatabaseSync(options.path).close(); + fs.writeFileSync(process.env.OPENCLAW_IDENTITY_READY_PATH, "ready"); + const deadline = Date.now() + 15_000; + while (!fs.existsSync(process.env.OPENCLAW_IDENTITY_CONTINUE_PATH)) { + if (Date.now() >= deadline) throw new Error("timed out waiting to continue bootstrap"); + await new Promise((resolve) => setTimeout(resolve, 2)); + } + const stored = insertStoredDeviceIdentityIfAbsent(generateStoredDeviceIdentity(), options); + fs.writeFileSync(process.env.OPENCLAW_IDENTITY_COMMITTED_PATH, "committed"); + console.log(JSON.stringify({ + deviceId: stored.deviceId, + publicKeyPem: stored.publicKeyPem, + privateKeyPem: stored.privateKeyPem, + })); + } finally { + coordinator.release(); + } + `; + const child = spawn( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", workerSource], + { + env: { + ...process.env, + OPENCLAW_IDENTITY_COMMITTED_PATH: committedPath, + OPENCLAW_COORDINATOR_MODULE: coordinatorModuleUrl, + OPENCLAW_IDENTITY_CONTINUE_PATH: continuePath, + OPENCLAW_IDENTITY_DATABASE_PATH: databasePath, + OPENCLAW_IDENTITY_READY_PATH: readyPath, + OPENCLAW_IDENTITY_STATE_DIR: rootDir, + OPENCLAW_IDENTITY_STORE_MODULE: storeModuleUrl, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const outcome = waitForChild(child); + const deadline = Date.now() + 15_000; + while (!fs.existsSync(readyPath)) { + if (child.exitCode !== null || child.signalCode !== null) { + await outcome; + } + if (Date.now() >= deadline) { + child.kill(); + throw new Error("timed out waiting for paused bootstrap creator"); + } + await new Promise((resolve) => { + setTimeout(resolve, 2); + }); + } + return { child, committedPath, continuePath, outcome }; +} + +function waitForFileSync(filePath: string): void { + const deadline = Date.now() + 15_000; + const waitBuffer = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(filePath)) { + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${filePath}`); + } + Atomics.wait(waitBuffer, 0, 0, 2); + } +} + describe("device identity SQLite store", () => { it("serializes identity ownership with the shared SQLite coordinator", async () => { await withTempDir("openclaw-device-identity-coordinator-", async (rootDir) => { @@ -262,12 +354,26 @@ describe("device identity SQLite store", () => { }); }); - it("reads a missing database without creating files", async () => { + it("reads a missing database without creating identity state or coordinator locks", async () => { await withTempDir("openclaw-device-identity-readonly-", async (rootDir) => { + const temporaryDirectory = path.join(rootDir, "tmp"); + fs.mkdirSync(temporaryDirectory); + vi.spyOn(os, "tmpdir").mockReturnValue(temporaryDirectory); const options = storeOptions(rootDir); + const coordinatorPaths = resolveDeviceIdentityCoordinatorPaths({ + databasePath: options.path!, + stateDir: rootDir, + temporaryDirectory, + uid: typeof process.getuid === "function" ? process.getuid() : undefined, + }); + expect(loadDeviceIdentityIfPresent(options)).toBeNull(); expect(fs.existsSync(options.path!)).toBe(false); expect(fs.existsSync(path.dirname(options.path!))).toBe(false); + expect(coordinatorPaths.every((coordinatorPath) => !fs.existsSync(coordinatorPath))).toBe( + true, + ); + expect(fs.existsSync(path.join(rootDir, "locks"))).toBe(false); }); }); @@ -315,6 +421,59 @@ describe("device identity SQLite store", () => { }); }); + it("keeps empty-bootstrap classification on the pre-commit read snapshot", async () => { + await withTempDir("openclaw-device-identity-bootstrap-read-", async (rootDir) => { + const creator = await startPausedBootstrapCreator(rootDir); + try { + const sqlite = await import("node:sqlite"); + // oxlint-disable-next-line typescript/unbound-method -- called below with the intercepted database receiver. + const prepare = sqlite.DatabaseSync.prototype.prepare; + let committedDuringRead = false; + vi.spyOn(sqlite.DatabaseSync.prototype, "prepare").mockImplementation( + function (this: InstanceType, sql) { + try { + return prepare.call(this, sql); + } catch (error) { + if (!committedDuringRead && /device_identities/iu.test(sql)) { + committedDuringRead = true; + fs.writeFileSync(creator.continuePath, "continue"); + waitForFileSync(creator.committedPath); + } + throw error; + } + }, + ); + + expect(loadDeviceIdentityIfPresent(storeOptions(rootDir))).toBeNull(); + expect(committedDuringRead).toBe(true); + + const committed = await creator.outcome; + expect(loadDeviceIdentityIfPresent(storeOptions(rootDir))).toEqual(committed); + } finally { + if (creator.child.exitCode === null && creator.child.signalCode === null) { + fs.writeFileSync(creator.continuePath, "continue"); + creator.child.kill(); + } + await Promise.allSettled([creator.outcome]); + } + }); + }, 30_000); + + it("does not classify a partial schema as an identity bootstrap miss", async () => { + await withTempDir("openclaw-device-identity-partial-schema-", async (rootDir) => { + const options = storeOptions(rootDir); + fs.mkdirSync(path.dirname(options.path!), { recursive: true }); + const sqlite = await import("node:sqlite"); + const database = new sqlite.DatabaseSync(options.path!); + database.exec("CREATE TABLE unrelated_state (id INTEGER PRIMARY KEY) STRICT;"); + database.close(); + + expect(() => loadDeviceIdentityIfPresent(options)).toThrow( + /no such table: device_identities/, + ); + }); + }); + it("adopts a Swift-created version-zero identity database and completes the shared schema", async () => { await withTempDir("openclaw-device-identity-swift-db-", async (rootDir) => { const options = storeOptions(rootDir); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 4e3aee9c92b6..fc61c42424d8 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -165,14 +165,12 @@ export function loadOrCreateProcessDeviceIdentity( export function loadDeviceIdentityIfPresent( options: DeviceIdentityStoreOptions = {}, ): DeviceIdentity | null { - return withDeviceIdentityCoordinator(options, (_resolved, resolvedOptions) => { - const stored = readStoredDeviceIdentityReadOnly(resolvedOptions); - if (stored) { - return toDeviceIdentity(stored); - } - assertNoPendingLegacyIdentity(resolvedOptions); - return null; - }); + const stored = readStoredDeviceIdentityReadOnly(options); + if (stored) { + return toDeviceIdentity(stored); + } + assertNoPendingLegacyIdentity(options); + return null; } /** Load a persisted identity without creating coordinator or shared-state artifacts. */ diff --git a/src/infra/sqlite-readonly-location.ts b/src/infra/sqlite-readonly-location.ts index 1ae43c562989..b5502830858b 100644 --- a/src/infra/sqlite-readonly-location.ts +++ b/src/infra/sqlite-readonly-location.ts @@ -34,7 +34,7 @@ type SourceSidecars = { wal: boolean; }; -type SourceJournalMode = "rollback" | "unknown" | "wal"; +type SourceJournalMode = "empty" | "rollback" | "unknown" | "wal"; type PreparedSqliteReadOnlyLocation = { cleanup: () => boolean; @@ -103,6 +103,9 @@ function readSourceJournalMode(pathname: string): SourceJournalMode { 0, ); assertPinnedIdentityUnchanged(source); + if (bytesRead === 0 && confirmedBytesRead === 0) { + return "empty"; + } if ( bytesRead !== header.length || confirmedBytesRead !== confirmedHeader.length || @@ -473,6 +476,17 @@ export async function prepareSqliteReadOnlyLocation( lastChange = error; continue; } + if (journalMode === "empty") { + try { + return await createStableReadOnlyCopy(canonicalPath, journalMode); + } catch (error) { + if (!(error instanceof SqliteSourceChangedError)) { + throw error; + } + lastChange = error; + continue; + } + } const sidecars = readSourceSidecars(canonicalPath); if (journalMode !== "wal" || (sidecars.wal && sidecars.shm)) { try {