diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts index 3df7fb8f1550..e7f9388c105e 100644 --- a/src/commands/backup-shared.ts +++ b/src/commands/backup-shared.ts @@ -290,7 +290,8 @@ export async function resolveBackupPlanFromDisk( const configPath = resolveConfigPath(); const oauthDir = resolveOAuthDir(); - const configSnapshot = await readConfigFileSnapshot(); + // Backup discovery must not initialize or migrate the state DB before snapshot validation. + const configSnapshot = await readConfigFileSnapshot({ observe: false }); if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) { throw new Error( `Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`, diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index e7f23c79fa6a..eaf47cf87240 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -20,7 +20,10 @@ import { sanitizeOpenClawGlobalStateSnapshot, sanitizeOpenClawStateLeaseRows, } from "../state/openclaw-state-snapshot-sanitizer.js"; -import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; +import { + type OpenClawTestState, + withOpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; import { createBackupArchive, formatBackupCreateSummary, @@ -111,6 +114,59 @@ function createUnsafeIndexDrift(sqlitePath: string): void { } } +function createEmptySqliteDatabase(sqlitePath: string): void { + const sqlite = requireNodeSqlite(); + const database = new sqlite.DatabaseSync(sqlitePath); + try { + database.exec("VACUUM;"); + } finally { + database.close(); + } +} + +function createOwnedSqliteDatabase(params: { + sqlitePath: string; + role: "agent" | "global"; + agentId?: string; + schemaVersion?: number; +}): void { + const sqlite = requireNodeSqlite(); + const database = new sqlite.DatabaseSync(params.sqlitePath); + const schemaVersion = params.schemaVersion ?? 1; + try { + database.exec(` + CREATE TABLE schema_meta ( + meta_key TEXT NOT NULL PRIMARY KEY, + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT, + app_version TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + PRAGMA user_version = ${schemaVersion}; + `); + database + .prepare( + `INSERT INTO schema_meta + (meta_key, role, schema_version, agent_id, app_version, created_at, updated_at) + VALUES ('primary', ?, ?, ?, NULL, 1, 1)`, + ) + .run(params.role, schemaVersion, params.agentId ?? null); + } finally { + database.close(); + } +} + +function resolveCanonicalTestSqlitePath( + state: OpenClawTestState, + kind: "agent" | "global", +): string { + return kind === "global" + ? resolveOpenClawStateSqlitePath(state.env) + : state.statePath("agents", "main", "agent", "openclaw-agent.sqlite"); +} + describe("formatBackupCreateSummary", () => { const backupArchiveLine = "Backup archive: /tmp/openclaw-backup.tar.gz"; @@ -1195,9 +1251,13 @@ describe("createBackupArchive", () => { PRAGMA wal_autocheckpoint = 0; CREATE TABLE schema_meta ( meta_key TEXT NOT NULL PRIMARY KEY, - role TEXT NOT NULL + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT ); - INSERT INTO schema_meta (meta_key, role) VALUES ('primary', 'agent'); + INSERT INTO schema_meta (meta_key, role, schema_version, agent_id) + VALUES ('primary', 'agent', 1, 'node_modules'); + PRAGMA user_version = 1; CREATE TABLE markers (id INTEGER PRIMARY KEY, value TEXT NOT NULL); PRAGMA wal_checkpoint(TRUNCATE); INSERT INTO markers (value) VALUES ('committed-in-wal'); @@ -1243,6 +1303,362 @@ describe("createBackupArchive", () => { ); }); + it.each([ + { + name: "global", + kind: "global" as const, + }, + { + name: "agent", + kind: "agent" as const, + }, + ])("rejects a zero-byte canonical $name database", async ({ kind }) => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-zero-byte-canonical-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const dbPath = resolveCanonicalTestSqlitePath(state, kind); + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(dbPath, ""); + + await expect( + createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 0, 0), + }), + ).rejects.toThrow(/snapshot source must not be empty/iu); + expect((await fs.stat(dbPath)).size).toBe(0); + expect(await fs.readdir(outputDir)).toEqual([]); + }, + ); + }); + + it.each([ + { + name: "global", + kind: "global" as const, + }, + { + name: "agent", + kind: "agent" as const, + }, + ])("rejects a schema-empty canonical $name database", async ({ kind }) => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-schema-empty-canonical-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const dbPath = resolveCanonicalTestSqlitePath(state, kind); + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + createEmptySqliteDatabase(dbPath); + + await expect( + createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 1, 0), + }), + ).rejects.toThrow(/schema role missing|no schema ownership metadata/iu); + const sqlite = requireNodeSqlite(); + const database = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 0 }); + expect( + database + .prepare( + "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'schema_meta'", + ) + .get(), + ).toEqual({ count: 0 }); + } finally { + database.close(); + } + expect(await fs.readdir(outputDir)).toEqual([]); + }, + ); + }); + + it.each([ + { + name: "global database with agent role", + kind: "global" as const, + role: "agent" as const, + agentId: "main", + expected: /schema role agent; expected global/iu, + }, + { + name: "agent database with global role", + kind: "agent" as const, + role: "global" as const, + expected: /schema role global; expected agent/iu, + }, + { + name: "agent database with a different owner", + kind: "agent" as const, + role: "agent" as const, + agentId: "worker", + expected: /belongs to agent worker; requested agent main/iu, + }, + { + name: "agent database with a noncanonical owner spelling", + kind: "agent" as const, + role: "agent" as const, + agentId: "Main", + expected: /belongs to agent Main; requested agent main/iu, + }, + ])("rejects a canonical $name", async ({ kind, role, agentId, expected }) => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-wrong-owner-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const dbPath = resolveCanonicalTestSqlitePath(state, kind); + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + createOwnedSqliteDatabase({ sqlitePath: dbPath, role, agentId }); + + await expect( + createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 2, 0), + }), + ).rejects.toThrow(expected); + expect(await fs.readdir(outputDir)).toEqual([]); + }, + ); + }); + + it("rejects a canonical agent database under a noncanonical agent path", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-noncanonical-agent-path-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const dbPath = state.statePath("agents", "Main", "agent", "openclaw-agent.sqlite"); + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + createOwnedSqliteDatabase({ + sqlitePath: dbPath, + role: "agent", + agentId: "main", + }); + + await expect( + createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 2, 30), + }), + ).rejects.toThrow(/noncanonical agent owner Main/iu); + expect(await fs.readdir(outputDir)).toEqual([]); + }, + ); + }); + + it("validates hard-linked canonical agent paths against each path owner", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-hardlinked-agent-owners-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const mainDbPath = state.statePath("agents", "main", "agent", "openclaw-agent.sqlite"); + const workerDbPath = state.statePath("agents", "worker", "agent", "openclaw-agent.sqlite"); + await fs.mkdir(path.dirname(mainDbPath), { recursive: true }); + await fs.mkdir(path.dirname(workerDbPath), { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + createOwnedSqliteDatabase({ + sqlitePath: mainDbPath, + role: "agent", + agentId: "main", + }); + await fs.link(mainDbPath, workerDbPath); + + await expect( + createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 2, 45), + }), + ).rejects.toThrow(/belongs to agent main; requested agent worker/iu); + expect(await fs.readdir(outputDir)).toEqual([]); + }, + ); + }); + + it("does not treat a canonical agent path as an alias of the global database", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-hardlinked-global-agent-owners-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const globalDbPath = resolveCanonicalTestSqlitePath(state, "global"); + const agentDbPath = resolveCanonicalTestSqlitePath(state, "agent"); + await fs.mkdir(path.dirname(globalDbPath), { recursive: true }); + await fs.mkdir(path.dirname(agentDbPath), { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + createOwnedSqliteDatabase({ + sqlitePath: globalDbPath, + role: "global", + }); + await fs.link(globalDbPath, agentDbPath); + + await expect( + createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 2, 50), + }), + ).rejects.toThrow(/schema role global; expected agent/iu); + expect(await fs.readdir(outputDir)).toEqual([]); + }, + ); + }); + + it.runIf(process.platform !== "win32")( + "fails closed when a canonical SQLite symlink retargets after discovery", + async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-canonical-symlink-retarget-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const canonicalDbPath = resolveCanonicalTestSqlitePath(state, "global"); + const firstDbPath = state.statePath("state", "first-global.sqlite"); + const secondDbPath = state.statePath("state", "second-global.sqlite"); + await fs.mkdir(path.dirname(canonicalDbPath), { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + createOwnedSqliteDatabase({ + sqlitePath: firstDbPath, + role: "global", + }); + createOwnedSqliteDatabase({ + sqlitePath: secondDbPath, + role: "global", + }); + await fs.symlink(firstDbPath, canonicalDbPath); + + const originalRealpath = fs.realpath.bind(fs); + let retargeted = false; + const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (target) => { + const resolved = await originalRealpath(target); + if (!retargeted && path.resolve(String(target)) === path.resolve(canonicalDbPath)) { + retargeted = true; + await fs.unlink(canonicalDbPath); + await fs.symlink(secondDbPath, canonicalDbPath); + } + return resolved; + }); + + try { + await expect( + createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 2, 55), + }), + ).rejects.toThrow(/Canonical SQLite path changed after discovery/iu); + expect(retargeted).toBe(true); + expect(await fs.readdir(outputDir)).toEqual([]); + } finally { + realpathSpy.mockRestore(); + } + }, + ); + }, + ); + + it("backs up older owned canonical databases and a generic schema-empty plugin database", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-owned-older-schema-", + scenario: "minimal", + }, + async (state) => { + const outputDir = state.path("backups"); + const globalDbPath = resolveCanonicalTestSqlitePath(state, "global"); + const agentDbPath = resolveCanonicalTestSqlitePath(state, "agent"); + const pluginDbPath = state.statePath("plugins", "dedicated", "empty.sqlite"); + for (const dbPath of [globalDbPath, agentDbPath, pluginDbPath]) { + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + } + await fs.mkdir(outputDir, { recursive: true }); + createOwnedSqliteDatabase({ + sqlitePath: globalDbPath, + role: "global", + schemaVersion: 1, + }); + createOwnedSqliteDatabase({ + sqlitePath: agentDbPath, + role: "agent", + agentId: "main", + schemaVersion: 1, + }); + createEmptySqliteDatabase(pluginDbPath); + + const result = await createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 6, 24, 9, 3, 0), + }); + const entries = await listArchiveEntries(result.archivePath); + expect(entries.some((entry) => entry.endsWith("/state/state/openclaw.sqlite"))).toBe(true); + expect( + entries.some((entry) => entry.endsWith("/state/agents/main/agent/openclaw-agent.sqlite")), + ).toBe(true); + expect( + entries.some((entry) => entry.endsWith("/state/plugins/dedicated/empty.sqlite")), + ).toBe(true); + + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + await expect( + backupVerifyCommand(runtime, { archive: result.archivePath }), + ).resolves.toMatchObject({ ok: true }); + + const sqlite = requireNodeSqlite(); + for (const dbPath of [globalDbPath, agentDbPath]) { + const database = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 }); + expect( + database + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'") + .get(), + ).toEqual({ schema_version: 1 }); + } finally { + database.close(); + } + } + }, + ); + }); + it("snapshots nested live SQLite databases with transaction continuity", async () => { await withOpenClawTestState( { @@ -1679,9 +2095,13 @@ describe("createBackupArchive", () => { ); CREATE TABLE schema_meta ( meta_key TEXT NOT NULL PRIMARY KEY, - role TEXT NOT NULL + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT ); - INSERT INTO schema_meta (meta_key, role) VALUES ('primary', 'global'); + INSERT INTO schema_meta (meta_key, role, schema_version, agent_id) + VALUES ('primary', 'global', 1, NULL); + PRAGMA user_version = 1; PRAGMA wal_checkpoint(TRUNCATE); INSERT INTO durable_state (id, value) VALUES (1, 'must-stay'); INSERT INTO delivery_queue_entries (id) VALUES ('must-drop'); @@ -1791,7 +2211,9 @@ describe("createBackupArchive", () => { PRAGMA wal_autocheckpoint = 0; CREATE TABLE schema_meta ( meta_key TEXT NOT NULL PRIMARY KEY, - role TEXT NOT NULL + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT ); CREATE TABLE durable_state ( id INTEGER PRIMARY KEY, @@ -1801,7 +2223,9 @@ describe("createBackupArchive", () => { scope TEXT NOT NULL, lease_key TEXT NOT NULL ); - INSERT INTO schema_meta (meta_key, role) VALUES ('primary', 'agent'); + INSERT INTO schema_meta (meta_key, role, schema_version, agent_id) + VALUES ('primary', 'agent', 1, 'main'); + PRAGMA user_version = 1; PRAGMA wal_checkpoint(TRUNCATE); INSERT INTO durable_state (id, value) VALUES (1, 'committed-in-wal'); INSERT INTO state_leases (scope, lease_key) VALUES ('plugin:memory-core:qmd', 'write'); diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 457f2c2a69d5..a3c6068d0a2e 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -1,6 +1,6 @@ // Creates backup archives while filtering volatile runtime state. import { randomUUID } from "node:crypto"; -import { constants as fsConstants } from "node:fs"; +import { constants as fsConstants, type Stats } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -13,7 +13,10 @@ import { resolveBackupPlanFromDisk, } from "../commands/backup-shared.js"; import { isPathWithin } from "../commands/cleanup-utils.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; +import { assertOpenClawAgentDatabaseOwner } from "../state/openclaw-agent-db-maintenance.js"; +import { assertOpenClawStateDatabaseOwner } from "../state/openclaw-state-db-maintenance.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { sanitizeOpenClawGlobalStateSnapshot, @@ -413,6 +416,12 @@ type SqliteBackupAsset = { skippedSourcePaths: Set; }; +type CanonicalSqliteSource = { + archiveSourcePath: string; + identity: Stats; + sourcePath: string; +} & ({ role: "global" } | { role: "agent"; agentId: string }); + type StateSqliteBackupPlan = { snapshots: SqliteBackupAsset[]; discoveredSourcePaths: Set; @@ -446,16 +455,26 @@ function isCanonicalAgentSqlitePathOrAncestor(sourcePath: string, stateDir: stri ); } -function isCanonicalAgentSqliteDatabasePath(sourcePath: string, stateDir: string): boolean { +function resolveCanonicalAgentSqliteDatabaseAgentId( + sourcePath: string, + stateDir: string, +): string | undefined { const relativePath = path.relative(path.resolve(stateDir), path.resolve(sourcePath)); const segments = relativePath.split(path.sep); - return ( + if ( segments.length === 4 && segments[0] === "agents" && Boolean(segments[1]) && segments[2] === "agent" && segments[3] === "openclaw-agent.sqlite" - ); + ) { + return segments[1]; + } + return undefined; +} + +function isCanonicalAgentSqliteDatabasePath(sourcePath: string, stateDir: string): boolean { + return resolveCanonicalAgentSqliteDatabaseAgentId(sourcePath, stateDir) !== undefined; } function isStatePackageContentPath(sourcePath: string, stateDir: string): boolean { @@ -651,47 +670,101 @@ async function createStateSqliteBackupPlan(params: { const canonicalGlobalSourcePath = globalStateIdentity ? await fs.realpath(globalStateSqlitePath) : globalStateSqlitePath; - const canonicalAgentSources = await Promise.all( - discovery.snapshotPaths - .filter((sourcePath) => isCanonicalAgentSqliteDatabasePath(sourcePath, params.stateDir)) - .map(async (sourcePath) => ({ - identity: await fs.stat(sourcePath), - sourcePath: await fs.realpath(sourcePath), - })), + const canonicalSources: CanonicalSqliteSource[] = []; + if (globalStateIdentity) { + canonicalSources.push({ + role: "global", + archiveSourcePath: globalStateSqlitePath, + identity: globalStateIdentity, + sourcePath: canonicalGlobalSourcePath, + }); + } + canonicalSources.push( + ...(await Promise.all( + discovery.snapshotPaths + .filter((sourcePath) => isCanonicalAgentSqliteDatabasePath(sourcePath, params.stateDir)) + .map(async (sourcePath) => { + const agentId = resolveCanonicalAgentSqliteDatabaseAgentId(sourcePath, params.stateDir); + if (!agentId) { + throw new Error(`Canonical agent SQLite path has no agent owner: ${sourcePath}`); + } + if (normalizeAgentId(agentId) !== agentId) { + throw new Error( + `Canonical agent SQLite path has a noncanonical agent owner ${agentId}: ${sourcePath}`, + ); + } + return { + role: "agent" as const, + agentId, + archiveSourcePath: sourcePath, + identity: await fs.stat(sourcePath), + sourcePath: await fs.realpath(sourcePath), + }; + }), + )), ); const snapshots: SqliteBackupAsset[] = []; for (const archiveSourcePath of discovery.snapshotPaths) { // A discovered *.sqlite file that SQLite cannot snapshot aborts backup. // Raw-copying malformed or unreadable databases would restore unsafe state. - // Resolve the canonical global path so a symlinked DB reads the target's - // live WAL/SHM state instead of looking for sidecars beside the symlink. const archiveSourceIdentity = await fs.stat(archiveSourcePath); - const isGlobalStateDatabase = - globalStateIdentity !== undefined && - sameFileIdentity(globalStateIdentity, archiveSourceIdentity); - const canonicalAgentSource = canonicalAgentSources.find((source) => - sameFileIdentity(source.identity, archiveSourceIdentity), + const exactCanonicalSource = canonicalSources.find( + (source) => path.resolve(source.archiveSourcePath) === path.resolve(archiveSourcePath), ); + if ( + exactCanonicalSource && + !sameFileIdentity(exactCanonicalSource.identity, archiveSourceIdentity) + ) { + throw new Error(`Canonical SQLite path changed after discovery: ${archiveSourcePath}`); + } + const matchingCanonicalSources = exactCanonicalSource + ? [exactCanonicalSource] + : canonicalSources.filter((source) => + sameFileIdentity(source.identity, archiveSourceIdentity), + ); + if (matchingCanonicalSources.length > 1) { + const owners = matchingCanonicalSources + .map((source) => (source.role === "global" ? "global" : `agent:${source.agentId}`)) + .join(", "); + throw new Error( + `SQLite path aliases multiple canonical database owners (${owners}): ${archiveSourcePath}`, + ); + } + const canonicalSource = matchingCanonicalSources[0]; // Every alias of a canonical DB must read that database's WAL and receive - // the same role-specific transient-row sanitizer. - const sourceDatabasePath = isGlobalStateDatabase - ? canonicalGlobalSourcePath - : (canonicalAgentSource?.sourcePath ?? archiveSourcePath); + // the same role-specific transient-row sanitizer. Exact canonical paths + // keep their own owner even when another canonical path shares the inode. + const sourceDatabasePath = canonicalSource?.sourcePath ?? archiveSourcePath; const sourcePath = path.join(params.tempDir, `openclaw-state-db-${snapshots.length}.sqlite`); try { await createVerifiedSqliteSnapshot({ sourcePath: sourceDatabasePath, targetPath: sourcePath, + requireNonEmptySource: Boolean(canonicalSource), + validate: + canonicalSource?.role === "global" + ? (database, pathname) => + assertOpenClawStateDatabaseOwner(database, { + pathname, + }) + : canonicalSource?.role === "agent" + ? (database, pathname) => + assertOpenClawAgentDatabaseOwner(database, { + agentId: canonicalSource.agentId, + pathname, + }) + : undefined, // Agent coordination is transient, while unrelated plugin databases // remain owner-defined. Queue and TTL-blob policy is global-only. - transform: isGlobalStateDatabase - ? (database) => { - sanitizeOpenClawGlobalStateSnapshot(database); - rewriteLegacyAuditBackupCheckpoints(database, params.legacyAuditSnapshots); - } - : canonicalAgentSource - ? sanitizeOpenClawStateLeaseRows - : undefined, + transform: + canonicalSource?.role === "global" + ? (database) => { + sanitizeOpenClawGlobalStateSnapshot(database); + rewriteLegacyAuditBackupCheckpoints(database, params.legacyAuditSnapshots); + } + : canonicalSource?.role === "agent" + ? sanitizeOpenClawStateLeaseRows + : undefined, }); } catch (err) { throw new Error( diff --git a/src/infra/sqlite-snapshot.test.ts b/src/infra/sqlite-snapshot.test.ts index fefc97cb284a..a71a110ecc26 100644 --- a/src/infra/sqlite-snapshot.test.ts +++ b/src/infra/sqlite-snapshot.test.ts @@ -53,6 +53,18 @@ function createUnsafeIndexDrift(sqlitePath: string): void { } } +function createEmptySqliteDatabase( + sqlite: ReturnType, + sqlitePath: string, +): void { + const database = new sqlite.DatabaseSync(sqlitePath); + try { + database.exec("VACUUM;"); + } finally { + database.close(); + } +} + describe("createVerifiedSqliteSnapshot", () => { it.runIf(process.platform === "win32")( "creates private staging directories exclusively under races", @@ -156,12 +168,41 @@ describe("createVerifiedSqliteSnapshot", () => { await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("snapshots a zero-byte generic source as an empty SQLite database", async () => { + const tempDir = await createTempDir(); + const sourcePath = path.join(tempDir, "source.sqlite"); + const targetPath = path.join(tempDir, "snapshot.sqlite"); + await fs.writeFile(sourcePath, ""); + + await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).resolves.toEqual({ + path: targetPath, + userVersion: 0, + }); + expect((await fs.stat(targetPath)).size).toBeGreaterThan(0); + }); + + it("rejects a zero-byte source when nonempty input is required", async () => { + const tempDir = await createTempDir(); + const sourcePath = path.join(tempDir, "source.sqlite"); + const targetPath = path.join(tempDir, "snapshot.sqlite"); + await fs.writeFile(sourcePath, ""); + + await expect( + createVerifiedSqliteSnapshot({ + sourcePath, + targetPath, + requireNonEmptySource: true, + }), + ).rejects.toThrow(/snapshot source must not be empty/u); + await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("rejects an existing target without modifying it", async () => { const tempDir = await createTempDir(); const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); await fs.writeFile(targetPath, "keep"); await expect(createVerifiedSqliteSnapshot({ sourcePath, targetPath })).rejects.toThrow( @@ -175,7 +216,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); await expect( createVerifiedSqliteSnapshot({ @@ -194,7 +235,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalOpen = fs.open.bind(fs); let stagedReadCount = 0; const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { @@ -227,7 +268,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); let guarded = false; await expect( @@ -249,7 +290,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); let guarded = false; await expect( @@ -271,7 +312,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const asynchronousGuard = (async () => {}) as unknown as () => void; await expect( @@ -289,7 +330,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const asynchronousFinalCheck = (async () => {}) as unknown as () => void; await expect( @@ -309,7 +350,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); await expect( createVerifiedSqliteSnapshot({ @@ -330,7 +371,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { await originalLink(source, target); @@ -357,7 +398,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { if (path.resolve(String(target)) === targetPath) { @@ -385,7 +426,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); const originalLstat = fs.lstat.bind(fs); let linked = false; @@ -420,7 +461,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalOpen = fs.open.bind(fs); const openSpy = vi.spyOn(fs, "open").mockImplementation(originalOpen); @@ -443,7 +484,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const linkSpy = vi .spyOn(fs, "link") .mockRejectedValue(Object.assign(new Error("hard links unsupported"), { code: "ENOTSUP" })); @@ -465,7 +506,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const linkSpy = vi.spyOn(fs, "link").mockImplementation(async (source, target) => { if (path.resolve(String(target)) === targetPath) { await fs.appendFile(source, "changed-before-fallback"); @@ -488,7 +529,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalLink = fs.link.bind(fs); const originalOpen = fs.open.bind(fs); let linked = false; @@ -521,7 +562,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalChmod = fs.chmod.bind(fs); const chmodSpy = vi.spyOn(fs, "chmod").mockImplementation(async (filePath, mode) => { if (path.basename(String(filePath)).startsWith(".sqlite-publish-")) { @@ -547,7 +588,7 @@ describe("createVerifiedSqliteSnapshot", () => { const sourcePath = path.join(tempDir, "source.sqlite"); const targetPath = path.join(tempDir, "snapshot.sqlite"); const sqlite = requireNodeSqlite(); - new sqlite.DatabaseSync(sourcePath).close(); + createEmptySqliteDatabase(sqlite, sourcePath); const originalOpen = fs.open.bind(fs); const openSpy = vi.spyOn(fs, "open").mockImplementation(async (filePath, flags, mode) => { if (path.resolve(String(filePath)) === tempDir) { diff --git a/src/infra/sqlite-snapshot.ts b/src/infra/sqlite-snapshot.ts index 80304261ee31..87aa70e8e133 100644 --- a/src/infra/sqlite-snapshot.ts +++ b/src/infra/sqlite-snapshot.ts @@ -96,6 +96,7 @@ type CreateVerifiedSqliteSnapshotOptions = { /** Final caller checks around publication; failures remove only this helper's target. */ afterPublish?: (guard: PublishedSqliteFileGuard) => void; beforePublish?: () => void | Promise; + requireNonEmptySource?: boolean; transform?: (database: DatabaseSync) => void | Promise; validate?: SqliteSnapshotValidator; }; @@ -194,11 +195,17 @@ export async function createPrivateSqliteTempDirectory( return directoryPath; } -async function assertRegularSourceFile(sourcePath: string): Promise { +async function assertRegularSourceFile( + sourcePath: string, + requireNonEmptySource: boolean, +): Promise { const stat = await fs.lstat(sourcePath); if (!stat.isFile()) { throw new Error(`SQLite snapshot source must be a regular file: ${sourcePath}`); } + if (requireNonEmptySource && stat.size === 0) { + throw new Error(`SQLite snapshot source must not be empty: ${sourcePath}`); + } } async function assertTargetAbsent(targetPath: string): Promise { @@ -787,7 +794,7 @@ async function removePublicationStagingDirectory( export async function createVerifiedSqliteSnapshot( options: CreateVerifiedSqliteSnapshotOptions, ): Promise { - await assertRegularSourceFile(options.sourcePath); + await assertRegularSourceFile(options.sourcePath, options.requireNonEmptySource === true); await assertTargetAbsent(options.targetPath); const stagingDir = await createPrivateSqliteTempDirectory( diff --git a/src/snapshot/local-repository.ts b/src/snapshot/local-repository.ts index 676b94de58e7..044ceabcb080 100644 --- a/src/snapshot/local-repository.ts +++ b/src/snapshot/local-repository.ts @@ -275,6 +275,7 @@ class LocalSqliteSnapshotProvider implements SqliteSnapshotProvider { const result = await createVerifiedSqliteSnapshot({ sourcePath, targetPath: artifactPath, + requireNonEmptySource: identity.role !== "generic", transform: identity.role === "global" ? sanitizeOpenClawGlobalStateSnapshot diff --git a/src/state/openclaw-agent-db-maintenance.ts b/src/state/openclaw-agent-db-maintenance.ts index e198f5b07f29..9167a08bdc56 100644 --- a/src/state/openclaw-agent-db-maintenance.ts +++ b/src/state/openclaw-agent-db-maintenance.ts @@ -36,11 +36,11 @@ const OPENCLAW_AGENT_MAINTENANCE_SCHEMA_COMPATIBILITY = { ], } satisfies SqliteSchemaCompatibility; -/** Require the exact agent owner and schema before offline file maintenance. */ -export function assertOpenClawAgentDatabaseForMaintenance( +/** Require exact agent ownership without requiring the latest schema. */ +export function assertOpenClawAgentDatabaseOwner( database: DatabaseSync, options: { agentId: string; pathname: string }, -): void { +): NonNullable> { const agentId = normalizeAgentId(options.agentId); const metadata = readExistingAgentSchemaMeta(database); if (!metadata) { @@ -49,6 +49,20 @@ export function assertOpenClawAgentDatabaseForMaintenance( ); } assertExistingAgentSchemaOwner(metadata, agentId, options.pathname); + if (metadata.agentId !== agentId) { + throw new Error( + `OpenClaw agent database ${options.pathname} belongs to agent ${metadata.agentId}; requested agent ${agentId}.`, + ); + } + return metadata; +} + +/** Require the exact agent owner and schema before offline file maintenance. */ +export function assertOpenClawAgentDatabaseForMaintenance( + database: DatabaseSync, + options: { agentId: string; pathname: string }, +): void { + const metadata = assertOpenClawAgentDatabaseOwner(database, options); const userVersion = readSqliteUserVersion(database); if (userVersion > OPENCLAW_AGENT_SCHEMA_VERSION) { diff --git a/src/state/openclaw-state-db-maintenance.ts b/src/state/openclaw-state-db-maintenance.ts index 47b7328ba8bb..4149a5103f0f 100644 --- a/src/state/openclaw-state-db-maintenance.ts +++ b/src/state/openclaw-state-db-maintenance.ts @@ -72,6 +72,28 @@ export function assertSupportedSchemaVersion(db: DatabaseSync, pathname: string) ); } } + +/** Require canonical shared-state ownership without requiring the latest schema. */ +export function assertOpenClawStateDatabaseOwner( + database: DatabaseSync, + options: { pathname: string }, +): void { + const hasMetadataTable = database + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_meta' LIMIT 1") + .get(); + const metadata = hasMetadataTable + ? (database.prepare("SELECT role FROM schema_meta WHERE meta_key = 'primary' LIMIT 1").get() as + | { role?: unknown } + | undefined) + : undefined; + if (metadata?.role !== "global") { + const role = typeof metadata?.role === "string" ? metadata.role : "missing"; + throw new Error( + `OpenClaw state database ${options.pathname} has schema role ${role}; expected global.`, + ); + } +} + /** Require the canonical shared-state owner and schema before offline file maintenance. */ export function assertOpenClawStateDatabaseForMaintenance( database: DatabaseSync, @@ -92,18 +114,13 @@ export function assertOpenClawStateDatabaseForMaintenance( ); } + assertOpenClawStateDatabaseOwner(database, options); const metadata = database - .prepare("SELECT role, schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1") - .get() as { role?: unknown; schema_version?: unknown } | undefined; - if (metadata?.role !== "global") { - const role = typeof metadata?.role === "string" ? metadata.role : "missing"; - throw new Error( - `OpenClaw state database ${options.pathname} has schema role ${role}; expected global.`, - ); - } - if (metadata.schema_version !== OPENCLAW_STATE_SCHEMA_VERSION) { + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1") + .get() as { schema_version?: unknown } | undefined; + if (metadata?.schema_version !== OPENCLAW_STATE_SCHEMA_VERSION) { const schemaVersion = - typeof metadata.schema_version === "number" ? metadata.schema_version : "invalid"; + typeof metadata?.schema_version === "number" ? metadata.schema_version : "invalid"; throw new Error( `OpenClaw state database ${options.pathname} metadata schema version ${schemaVersion} does not match ${OPENCLAW_STATE_SCHEMA_VERSION}; run openclaw doctor --fix before compacting it.`, );