mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(backup): validate canonical sqlite ownership (#113287)
This commit is contained in:
@@ -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.`,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -275,6 +275,7 @@ class LocalSqliteSnapshotProvider implements SqliteSnapshotProvider {
|
||||
const result = await createVerifiedSqliteSnapshot({
|
||||
sourcePath,
|
||||
targetPath: artifactPath,
|
||||
requireNonEmptySource: identity.role !== "generic",
|
||||
transform:
|
||||
identity.role === "global"
|
||||
? sanitizeOpenClawGlobalStateSnapshot
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user