mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 04:31:35 +00:00
* feat(backup): expose verified SQLite snapshots Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * feat(sqlite): add verified snapshot repository (#105525) Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * test(sqlite): use canonical snapshot schemas * fix(backup): restrict SQLite snapshot roles * fix(sqlite): harden snapshot staging paths Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * fix(sqlite): pin snapshot repository paths Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * fix(sqlite): secure Windows snapshot staging Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * fix(sqlite): validate macOS ACL captures * test(sqlite): preserve stat uid type * style(sqlite): satisfy snapshot lint --------- Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
291 lines
9.8 KiB
TypeScript
291 lines
9.8 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
|
import { requireNodeSqlite } from "../infra/node-sqlite.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { createLocalSqliteSnapshotProvider } from "../snapshot/local-repository.js";
|
|
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../state/openclaw-agent-db.js";
|
|
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js";
|
|
import { OPENCLAW_AGENT_SCHEMA_SQL } from "../state/openclaw-agent-schema.generated.js";
|
|
import { OPENCLAW_STATE_SCHEMA_VERSION } from "../state/openclaw-state-db.js";
|
|
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
|
import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.generated.js";
|
|
import {
|
|
backupSqliteCreateCommand,
|
|
backupSqliteListCommand,
|
|
backupSqliteRestoreCommand,
|
|
backupSqliteVerifyCommand,
|
|
} from "./backup-sqlite.js";
|
|
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
let previousStateDir: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (previousStateDir === undefined) {
|
|
delete process.env.OPENCLAW_STATE_DIR;
|
|
} else {
|
|
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
|
}
|
|
});
|
|
|
|
function createGlobalDatabase(databasePath: string): void {
|
|
const sqlite = requireNodeSqlite();
|
|
const database = new sqlite.DatabaseSync(databasePath);
|
|
try {
|
|
database.exec(`
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA wal_autocheckpoint = 0;
|
|
${OPENCLAW_STATE_SCHEMA_SQL}
|
|
PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};
|
|
CREATE TABLE durable_entries (
|
|
id INTEGER PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
`);
|
|
database
|
|
.prepare(
|
|
`
|
|
INSERT INTO schema_meta (
|
|
meta_key,
|
|
role,
|
|
schema_version,
|
|
agent_id,
|
|
app_version,
|
|
created_at,
|
|
updated_at
|
|
) VALUES ('primary', 'global', ?, NULL, NULL, 1, 1)
|
|
`,
|
|
)
|
|
.run(OPENCLAW_STATE_SCHEMA_VERSION);
|
|
database
|
|
.prepare(
|
|
`
|
|
INSERT INTO delivery_queue_entries (
|
|
queue_name,
|
|
id,
|
|
status,
|
|
entry_json,
|
|
enqueued_at,
|
|
updated_at
|
|
) VALUES ('delivery', 'queued', 'pending', ?, 1, 1)
|
|
`,
|
|
)
|
|
.run('{"payload":"do-not-restore"}');
|
|
database.prepare("INSERT INTO durable_entries (value) VALUES (?)").run("checkpointed");
|
|
database.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
|
database.prepare("INSERT INTO durable_entries (value) VALUES (?)").run("committed-in-wal");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
function createAgentDatabase(databasePath: string, agentId: string): void {
|
|
const sqlite = requireNodeSqlite();
|
|
const database = new sqlite.DatabaseSync(databasePath);
|
|
try {
|
|
database.exec(`
|
|
${OPENCLAW_AGENT_SCHEMA_SQL}
|
|
PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION};
|
|
CREATE TABLE durable_entries (
|
|
id INTEGER PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
`);
|
|
database
|
|
.prepare(
|
|
`
|
|
INSERT INTO schema_meta (
|
|
meta_key,
|
|
role,
|
|
schema_version,
|
|
agent_id,
|
|
app_version,
|
|
created_at,
|
|
updated_at
|
|
) VALUES ('primary', 'agent', ?, ?, NULL, 1, 1)
|
|
`,
|
|
)
|
|
.run(OPENCLAW_AGENT_SCHEMA_VERSION, agentId);
|
|
database.prepare("INSERT INTO durable_entries (value) VALUES (?)").run("agent-state");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
describe("SQLite backup commands", () => {
|
|
it("creates, lists, verifies, and fresh-restores the global database", async () => {
|
|
const tempDir = tempDirs.make("openclaw-backup-sqlite-");
|
|
const stateDir = path.join(tempDir, "state");
|
|
const repositoryPath = path.join(tempDir, "snapshots");
|
|
const scratchPath = path.join(tempDir, "scratch");
|
|
const restorePath = path.join(tempDir, "restore", "openclaw.sqlite");
|
|
process.env.OPENCLAW_STATE_DIR = stateDir;
|
|
const databasePath = resolveOpenClawStateSqlitePath();
|
|
await fs.mkdir(path.dirname(databasePath), { recursive: true });
|
|
await fs.mkdir(scratchPath, { mode: 0o700 });
|
|
await fs.chmod(scratchPath, 0o700);
|
|
createGlobalDatabase(databasePath);
|
|
const runtime = createRuntimeCapture();
|
|
|
|
const created = await backupSqliteCreateCommand(runtime, {
|
|
global: true,
|
|
repository: repositoryPath,
|
|
json: true,
|
|
});
|
|
expect(created.manifest.database).toMatchObject({
|
|
role: "global",
|
|
basename: "openclaw.sqlite",
|
|
userVersion: OPENCLAW_STATE_SCHEMA_VERSION,
|
|
});
|
|
expect(JSON.parse(runtime.logs.shift() ?? "{}")).toEqual(created);
|
|
|
|
const listed = await backupSqliteListCommand(runtime, {
|
|
repository: repositoryPath,
|
|
json: true,
|
|
});
|
|
expect(listed.snapshots).toHaveLength(1);
|
|
expect(listed.snapshots[0]?.manifest.snapshotId).toBe(created.manifest.snapshotId);
|
|
|
|
const verified = await backupSqliteVerifyCommand(runtime, created.snapshotPath, {
|
|
scratch: scratchPath,
|
|
json: true,
|
|
});
|
|
expect(verified.manifest).toEqual(created.manifest);
|
|
await expect(fs.readdir(scratchPath)).resolves.toEqual([]);
|
|
|
|
const restored = await backupSqliteRestoreCommand(runtime, created.snapshotPath, {
|
|
target: restorePath,
|
|
json: true,
|
|
});
|
|
expect(restored).toMatchObject({
|
|
ok: true,
|
|
snapshotPath: created.snapshotPath,
|
|
targetPath: restorePath,
|
|
});
|
|
expect(runtime.errors).toEqual([]);
|
|
|
|
const sqlite = requireNodeSqlite();
|
|
const restoredDatabase = new sqlite.DatabaseSync(restorePath, { readOnly: true });
|
|
try {
|
|
expect(
|
|
restoredDatabase.prepare("SELECT value FROM durable_entries ORDER BY id").all(),
|
|
).toEqual([{ value: "checkpointed" }, { value: "committed-in-wal" }]);
|
|
expect(
|
|
restoredDatabase.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get(),
|
|
).toEqual({ count: 0 });
|
|
} finally {
|
|
restoredDatabase.close();
|
|
}
|
|
});
|
|
|
|
it("creates a snapshot for a normalized per-agent database", async () => {
|
|
const tempDir = tempDirs.make("openclaw-backup-sqlite-");
|
|
const stateDir = path.join(tempDir, "state");
|
|
const repositoryPath = path.join(tempDir, "snapshots");
|
|
process.env.OPENCLAW_STATE_DIR = stateDir;
|
|
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "ops-team" });
|
|
await fs.mkdir(path.dirname(databasePath), { recursive: true });
|
|
createAgentDatabase(databasePath, "ops-team");
|
|
const runtime = createRuntimeCapture();
|
|
|
|
const created = await backupSqliteCreateCommand(runtime, {
|
|
agent: "Ops Team",
|
|
repository: repositoryPath,
|
|
});
|
|
|
|
expect(created.manifest.database).toEqual({
|
|
role: "agent",
|
|
agentId: "ops-team",
|
|
basename: "openclaw-agent.sqlite",
|
|
userVersion: OPENCLAW_AGENT_SCHEMA_VERSION,
|
|
});
|
|
expect(runtime.logs).toEqual([expect.stringContaining("Database: agent:ops-team")]);
|
|
expect(runtime.errors).toEqual([]);
|
|
});
|
|
|
|
it("requires exactly one named OpenClaw database source", async () => {
|
|
const runtime = createRuntimeCapture();
|
|
|
|
await expect(
|
|
backupSqliteCreateCommand(runtime, { repository: "/tmp/snapshots" }),
|
|
).rejects.toThrow("Choose a SQLite snapshot source");
|
|
await expect(
|
|
backupSqliteCreateCommand(runtime, {
|
|
global: true,
|
|
agent: "main",
|
|
repository: "/tmp/snapshots",
|
|
}),
|
|
).rejects.toThrow("Choose exactly one SQLite snapshot source");
|
|
});
|
|
|
|
it("requires repository, snapshot, and restore target paths", async () => {
|
|
const runtime = createRuntimeCapture();
|
|
|
|
await expect(backupSqliteCreateCommand(runtime, { global: true })).rejects.toThrow(
|
|
"Missing required --repository value",
|
|
);
|
|
await expect(backupSqliteVerifyCommand(runtime, " ", {})).rejects.toThrow(
|
|
"Missing required <snapshot> value",
|
|
);
|
|
await expect(backupSqliteRestoreCommand(runtime, "/tmp/snapshot", {})).rejects.toThrow(
|
|
"Missing required --target value",
|
|
);
|
|
});
|
|
|
|
it("rejects generic provider artifacts before verify or restore", async () => {
|
|
const tempDir = tempDirs.make("openclaw-backup-sqlite-");
|
|
const databasePath = path.join(tempDir, "generic.sqlite");
|
|
const repositoryPath = path.join(tempDir, "snapshots");
|
|
const restorePath = path.join(tempDir, "restore", "generic.sqlite");
|
|
const sqlite = requireNodeSqlite();
|
|
const database = new sqlite.DatabaseSync(databasePath);
|
|
try {
|
|
database.exec("CREATE TABLE entries (id INTEGER PRIMARY KEY);");
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const snapshot = await createLocalSqliteSnapshotProvider({ repositoryPath }).create({
|
|
path: databasePath,
|
|
identity: { role: "generic", id: "generic-test" },
|
|
});
|
|
const runtime = createRuntimeCapture();
|
|
|
|
await expect(backupSqliteListCommand(runtime, { repository: repositoryPath })).rejects.toThrow(
|
|
/database role generic is not allowed/u,
|
|
);
|
|
await expect(backupSqliteVerifyCommand(runtime, snapshot.ref.path, {})).rejects.toThrow(
|
|
/database role generic is not allowed/u,
|
|
);
|
|
await expect(
|
|
backupSqliteRestoreCommand(runtime, snapshot.ref.path, { target: restorePath }),
|
|
).rejects.toThrow(/database role generic is not allowed/u);
|
|
await expect(fs.access(restorePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
});
|
|
|
|
function createRuntimeCapture(): RuntimeEnv & {
|
|
logs: string[];
|
|
errors: string[];
|
|
} {
|
|
const logs: string[] = [];
|
|
const errors: string[] = [];
|
|
return {
|
|
logs,
|
|
errors,
|
|
log(value) {
|
|
logs.push(String(value));
|
|
},
|
|
error(value) {
|
|
errors.push(String(value));
|
|
},
|
|
exit(code) {
|
|
throw new Error(`exit ${code}`);
|
|
},
|
|
};
|
|
}
|