fix(backup): validate canonical sqlite ownership (#113287)

This commit is contained in:
Vincent Koc
2026-07-24 17:10:58 +08:00
committed by GitHub
parent b233faa057
commit f5b3f2a89d
8 changed files with 648 additions and 70 deletions
+2 -1
View File
@@ -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.`,
+431 -7
View File
@@ -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');
+103 -30
View File
@@ -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<string>;
};
type CanonicalSqliteSource = {
archiveSourcePath: string;
identity: Stats;
sourcePath: string;
} & ({ role: "global" } | { role: "agent"; agentId: string });
type StateSqliteBackupPlan = {
snapshots: SqliteBackupAsset[];
discoveredSourcePaths: Set<string>;
@@ -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(
+58 -17
View File
@@ -53,6 +53,18 @@ function createUnsafeIndexDrift(sqlitePath: string): void {
}
}
function createEmptySqliteDatabase(
sqlite: ReturnType<typeof requireNodeSqlite>,
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) {
+9 -2
View File
@@ -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<void>;
requireNonEmptySource?: boolean;
transform?: (database: DatabaseSync) => void | Promise<void>;
validate?: SqliteSnapshotValidator;
};
@@ -194,11 +195,17 @@ export async function createPrivateSqliteTempDirectory(
return directoryPath;
}
async function assertRegularSourceFile(sourcePath: string): Promise<void> {
async function assertRegularSourceFile(
sourcePath: string,
requireNonEmptySource: boolean,
): Promise<void> {
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<void> {
@@ -787,7 +794,7 @@ async function removePublicationStagingDirectory(
export async function createVerifiedSqliteSnapshot(
options: CreateVerifiedSqliteSnapshotOptions,
): Promise<VerifiedSqliteSnapshot> {
await assertRegularSourceFile(options.sourcePath);
await assertRegularSourceFile(options.sourcePath, options.requireNonEmptySource === true);
await assertTargetAbsent(options.targetPath);
const stagingDir = await createPrivateSqliteTempDirectory(
+1
View File
@@ -275,6 +275,7 @@ class LocalSqliteSnapshotProvider implements SqliteSnapshotProvider {
const result = await createVerifiedSqliteSnapshot({
sourcePath,
targetPath: artifactPath,
requireNonEmptySource: identity.role !== "generic",
transform:
identity.role === "global"
? sanitizeOpenClawGlobalStateSnapshot
+17 -3
View File
@@ -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<ReturnType<typeof readExistingAgentSchemaMeta>> {
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) {
+27 -10
View File
@@ -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.`,
);