feat(backup): add verified SQLite snapshots (#105718)

* 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>
This commit is contained in:
Vincent Koc
2026-07-13 06:27:14 +08:00
committed by GitHub
parent 95f142bf0c
commit 2f25e9cba3
14 changed files with 4508 additions and 8 deletions

View File

@@ -34,7 +34,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
},
{
name: "backup",
description: "Create and verify local backup archives for OpenClaw state",
description: "Create and verify backup archives and SQLite snapshots",
hasSubcommands: true,
},
{

View File

@@ -719,6 +719,25 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("bypasses config guard for SQLite snapshot recovery commands", async () => {
await runPreAction({
parseArgv: ["backup", "sqlite", "restore"],
processArgv: [
"node",
"openclaw",
"backup",
"sqlite",
"restore",
"/tmp/snapshot",
"--target",
"/tmp/restore.sqlite",
],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("routes logs to stderr during plugin loading in --json mode and restores after", async () => {
let stderrDuringPluginLoad = false;
ensurePluginRegistryLoadedMock.mockImplementation(() => {

View File

@@ -5,6 +5,10 @@ import { registerBackupCommand } from "./register.backup.js";
const mocks = vi.hoisted(() => ({
backupCreateCommand: vi.fn(),
backupSqliteCreateCommand: vi.fn(),
backupSqliteListCommand: vi.fn(),
backupSqliteRestoreCommand: vi.fn(),
backupSqliteVerifyCommand: vi.fn(),
backupVerifyCommand: vi.fn(),
runtime: {
log: vi.fn(),
@@ -14,6 +18,10 @@ const mocks = vi.hoisted(() => ({
}));
const backupCreateCommand = mocks.backupCreateCommand;
const backupSqliteCreateCommand = mocks.backupSqliteCreateCommand;
const backupSqliteListCommand = mocks.backupSqliteListCommand;
const backupSqliteRestoreCommand = mocks.backupSqliteRestoreCommand;
const backupSqliteVerifyCommand = mocks.backupSqliteVerifyCommand;
const backupVerifyCommand = mocks.backupVerifyCommand;
const runtime = mocks.runtime;
@@ -25,6 +33,13 @@ vi.mock("../../commands/backup-verify.js", () => ({
backupVerifyCommand: mocks.backupVerifyCommand,
}));
vi.mock("../../commands/backup-sqlite.js", () => ({
backupSqliteCreateCommand: mocks.backupSqliteCreateCommand,
backupSqliteListCommand: mocks.backupSqliteListCommand,
backupSqliteRestoreCommand: mocks.backupSqliteRestoreCommand,
backupSqliteVerifyCommand: mocks.backupSqliteVerifyCommand,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
@@ -39,6 +54,10 @@ describe("registerBackupCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
backupCreateCommand.mockResolvedValue(undefined);
backupSqliteCreateCommand.mockResolvedValue(undefined);
backupSqliteListCommand.mockResolvedValue(undefined);
backupSqliteRestoreCommand.mockResolvedValue(undefined);
backupSqliteVerifyCommand.mockResolvedValue(undefined);
backupVerifyCommand.mockResolvedValue(undefined);
});
@@ -93,4 +112,91 @@ describe("registerBackupCommand", () => {
expect(options.archive).toBe("/tmp/openclaw-backup.tar.gz");
expect(options.json).toBe(true);
});
it("registers the SQLite snapshot command group", () => {
const program = new Command();
registerBackupCommand(program);
const backup = program.commands.find((command) => command.name() === "backup");
const sqlite = backup?.commands.find((command) => command.name() === "sqlite");
expect(sqlite?.commands.map((command) => command.name()).toSorted()).toEqual([
"create",
"list",
"restore",
"verify",
]);
});
it("runs SQLite snapshot create for named OpenClaw databases", async () => {
await runCli([
"backup",
"sqlite",
"create",
"--global",
"--repository",
"/tmp/snapshots",
"--json",
]);
expect(backupSqliteCreateCommand).toHaveBeenCalledWith(runtime, {
global: true,
agent: undefined,
repository: "/tmp/snapshots",
json: true,
});
await runCli([
"backup",
"sqlite",
"create",
"--agent",
"main",
"--repository",
"/tmp/snapshots",
]);
expect(backupSqliteCreateCommand).toHaveBeenLastCalledWith(runtime, {
global: false,
agent: "main",
repository: "/tmp/snapshots",
json: false,
});
});
it("runs SQLite snapshot list, verify, and restore", async () => {
await runCli(["backup", "sqlite", "list", "--repository", "/tmp/snapshots", "--json"]);
expect(backupSqliteListCommand).toHaveBeenCalledWith(runtime, {
repository: "/tmp/snapshots",
json: true,
});
await runCli([
"backup",
"sqlite",
"verify",
"/tmp/snapshots/one",
"--scratch",
"/tmp/private-scratch",
"--json",
]);
expect(backupSqliteVerifyCommand).toHaveBeenCalledWith(runtime, "/tmp/snapshots/one", {
scratch: "/tmp/private-scratch",
json: true,
});
await runCli([
"backup",
"sqlite",
"restore",
"/tmp/snapshots/one",
"--target",
"/tmp/restored.sqlite",
"--json",
]);
expect(backupSqliteRestoreCommand).toHaveBeenCalledWith(runtime, "/tmp/snapshots/one", {
target: "/tmp/restored.sqlite",
json: true,
});
});
});

View File

@@ -2,6 +2,12 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import {
backupSqliteCreateCommand,
backupSqliteListCommand,
backupSqliteRestoreCommand,
backupSqliteVerifyCommand,
} from "../../commands/backup-sqlite.js";
import { backupVerifyCommand } from "../../commands/backup-verify.js";
import { backupCreateCommand } from "../../commands/backup.js";
import { defaultRuntime } from "../../runtime.js";
@@ -12,7 +18,7 @@ import { formatHelpExamples } from "../help-format.js";
export function registerBackupCommand(program: Command) {
const backup = program
.command("backup")
.description("Create and verify local backup archives for OpenClaw state")
.description("Create and verify backup archives and SQLite snapshots")
.addHelpText(
"after",
() =>
@@ -91,4 +97,90 @@ export function registerBackupCommand(program: Command) {
});
});
});
registerBackupSqliteCommands(backup);
}
function registerBackupSqliteCommands(backup: Command): void {
const sqlite = backup
.command("sqlite")
.description("Create, list, verify, and restore SQLite snapshots")
.action(() => {
sqlite.outputHelp();
process.exitCode = 1;
});
sqlite
.command("create")
.description("Create a compact, verified snapshot of an OpenClaw SQLite database")
.option("--global", "Snapshot the shared OpenClaw state database", false)
.option("--agent <id>", "Snapshot one per-agent OpenClaw database")
.requiredOption("--repository <path>", "Snapshot repository directory")
.option("--json", "Output JSON", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
[
"openclaw backup sqlite create --global --repository ~/Backups/openclaw-sqlite",
"Snapshot the shared state database.",
],
[
"openclaw backup sqlite create --agent main --repository ~/Backups/openclaw-sqlite",
"Snapshot the main agent database.",
],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupSqliteCreateCommand(defaultRuntime, {
global: Boolean(opts.global),
agent: opts.agent as string | undefined,
repository: opts.repository as string,
json: Boolean(opts.json),
});
});
});
sqlite
.command("list")
.description("List committed snapshots in a repository")
.requiredOption("--repository <path>", "Snapshot repository directory")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupSqliteListCommand(defaultRuntime, {
repository: opts.repository as string,
json: Boolean(opts.json),
});
});
});
sqlite
.command("verify <snapshot>")
.description("Verify a snapshot manifest, artifact hash, SQLite integrity, and database owner")
.option("--scratch <path>", "Existing private directory for verification copies")
.option("--json", "Output JSON", false)
.action(async (snapshot, opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupSqliteVerifyCommand(defaultRuntime, snapshot as string, {
scratch: opts.scratch as string | undefined,
json: Boolean(opts.json),
});
});
});
sqlite
.command("restore <snapshot>")
.description("Restore a verified snapshot to a new SQLite database path")
.requiredOption("--target <path>", "Fresh target path; existing files and sidecars are refused")
.option("--json", "Output JSON", false)
.action(async (snapshot, opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupSqliteRestoreCommand(defaultRuntime, snapshot as string, {
target: opts.target as string,
json: Boolean(opts.json),
});
});
});
}