diff --git a/src/commands/backup-restore.test.ts b/src/commands/backup-restore.test.ts index 6d129c31a19..10261825517 100644 --- a/src/commands/backup-restore.test.ts +++ b/src/commands/backup-restore.test.ts @@ -92,6 +92,7 @@ describe("backupRestoreCommand", () => { afterEach(async () => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -118,6 +119,7 @@ describe("backupRestoreCommand", () => { await fs.writeFile(path.join(restoredStateDir, "state.txt"), "restored\n"); await createSqliteDb(path.join(restoredStateDir, "state", "openclaw.sqlite"), "restored"); await createBackupArchive({ archivePath, sourceStateDir, restoredStateDir }); + vi.stubEnv("OPENCLAW_STATE_DIR", sourceStateDir); const runtime = createRuntime(); const result = await backupRestoreCommand(runtime, { archive: archivePath, dryRun: true }); @@ -130,7 +132,7 @@ describe("backupRestoreCommand", () => { expect(await fs.readFile(path.join(sourceStateDir, "state.txt"), "utf8")).toBe("current\n"); }); - it("restores verified SQLite snapshots to their recorded source paths", async () => { + it("restores verified SQLite snapshots to the current state path", async () => { const sourceStateDir = path.join(tempDir, "state"); const restoredStateDir = path.join(tempDir, "snapshot-state"); const archivePath = path.join(tempDir, "backup.tar.gz"); @@ -141,6 +143,7 @@ describe("backupRestoreCommand", () => { await fs.writeFile(path.join(restoredStateDir, "state.txt"), "restored\n"); await createSqliteDb(path.join(restoredStateDir, "state", "openclaw.sqlite"), "restored"); await createBackupArchive({ archivePath, sourceStateDir, restoredStateDir }); + vi.stubEnv("OPENCLAW_STATE_DIR", sourceStateDir); const runtime = createRuntime(); const result = await backupRestoreCommand(runtime, { archive: archivePath, yes: true }); @@ -152,4 +155,44 @@ describe("backupRestoreCommand", () => { expect(await fs.readFile(path.join(sourceStateDir, "state.txt"), "utf8")).toBe("restored\n"); expect(readSqliteValue(path.join(sourceStateDir, "state", "openclaw.sqlite"))).toBe("restored"); }); + + it("does not trust archived source paths as restore destinations", async () => { + const currentStateDir = path.join(tempDir, "current-state"); + const archivedSourceStateDir = path.join(tempDir, "archived-machine-state"); + const restoredStateDir = path.join(tempDir, "snapshot-state"); + const archivePath = path.join(tempDir, "backup.tar.gz"); + await fs.mkdir(currentStateDir, { recursive: true }); + await fs.writeFile(path.join(currentStateDir, "state.txt"), "current\n"); + await createSqliteDb(path.join(currentStateDir, "state", "openclaw.sqlite"), "current"); + await fs.mkdir(archivedSourceStateDir, { recursive: true }); + await fs.writeFile(path.join(archivedSourceStateDir, "state.txt"), "archived-machine\n"); + await fs.mkdir(restoredStateDir, { recursive: true }); + await fs.writeFile(path.join(restoredStateDir, "state.txt"), "restored\n"); + await createSqliteDb(path.join(restoredStateDir, "state", "openclaw.sqlite"), "restored"); + await createBackupArchive({ + archivePath, + sourceStateDir: archivedSourceStateDir, + restoredStateDir, + }); + vi.stubEnv("OPENCLAW_STATE_DIR", currentStateDir); + + const runtime = createRuntime(); + const result = await backupRestoreCommand(runtime, { archive: archivePath, yes: true }); + + expect(result?.restoredAssets).toEqual([ + expect.objectContaining({ + kind: "state", + originalSourcePath: archivedSourceStateDir, + sourcePath: currentStateDir, + status: "restored", + }), + ]); + expect(await fs.readFile(path.join(currentStateDir, "state.txt"), "utf8")).toBe("restored\n"); + expect(readSqliteValue(path.join(currentStateDir, "state", "openclaw.sqlite"))).toBe( + "restored", + ); + expect(await fs.readFile(path.join(archivedSourceStateDir, "state.txt"), "utf8")).toBe( + "archived-machine\n", + ); + }); }); diff --git a/src/commands/backup-restore.ts b/src/commands/backup-restore.ts index 7bd8c507376..1205f8daf0f 100644 --- a/src/commands/backup-restore.ts +++ b/src/commands/backup-restore.ts @@ -3,8 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import * as tar from "tar"; +import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/config.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { shortenHomePath, resolveUserPath } from "../utils.js"; +import { resolveBackupPlanFromDisk, type BackupAssetKind } from "./backup-shared.js"; import { verifyBackupArchive } from "./backup-verify.js"; export type BackupRestoreOptions = { @@ -21,6 +23,7 @@ export type BackupRestoreResult = { verified: true; restoredAssets: Array<{ kind: string; + originalSourcePath: string; sourcePath: string; archivePath: string; status: "planned" | "restored"; @@ -76,6 +79,80 @@ async function replacePathFromExtracted(params: { } } +function normalizeRestoreAssetKind(kind: string): BackupAssetKind { + switch (kind) { + case "state": + case "config": + case "credentials": + case "workspace": + return kind; + default: + throw new Error(`Backup restore does not support asset kind: ${kind}`); + } +} + +async function resolveWorkspaceRestoreTargets(): Promise> { + const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true }); + return new Set(plan.workspaceDirs.map((workspaceDir) => path.resolve(workspaceDir))); +} + +async function resolveBackupRestoreTarget(params: { + kind: string; + sourcePath: string; + workspaceTargets?: Set; +}): Promise { + const kind = normalizeRestoreAssetKind(params.kind); + switch (kind) { + case "state": + return path.resolve(resolveStateDir()); + case "config": + return path.resolve(resolveConfigPath()); + case "credentials": + return path.resolve(resolveOAuthDir()); + case "workspace": { + const sourcePath = path.resolve(params.sourcePath); + const workspaceTargets = params.workspaceTargets ?? (await resolveWorkspaceRestoreTargets()); + if (!workspaceTargets.has(sourcePath)) { + throw new Error( + `Backup workspace restore target is not in the current OpenClaw workspace configuration: ${params.sourcePath}`, + ); + } + return sourcePath; + } + } +} + +async function resolveBackupRestoreAssets( + assets: Array<{ kind: string; sourcePath: string; archivePath: string }>, +): Promise { + const needsWorkspaceTargets = assets.some((asset) => asset.kind === "workspace"); + const workspaceTargets = needsWorkspaceTargets + ? await resolveWorkspaceRestoreTargets() + : undefined; + const seenTargets = new Set(); + const restoredAssets: BackupRestoreResult["restoredAssets"] = []; + for (const asset of assets) { + const sourcePath = await resolveBackupRestoreTarget({ + kind: asset.kind, + sourcePath: asset.sourcePath, + workspaceTargets, + }); + const targetKey = path.resolve(sourcePath); + if (seenTargets.has(targetKey)) { + throw new Error(`Backup restore contains duplicate target path: ${sourcePath}`); + } + seenTargets.add(targetKey); + restoredAssets.push({ + kind: asset.kind, + originalSourcePath: asset.sourcePath, + sourcePath, + archivePath: asset.archivePath, + status: "planned", + }); + } + return restoredAssets; +} + function formatBackupRestoreSummary(result: BackupRestoreResult): string[] { const lines = [ `Backup archive: ${result.archivePath}`, @@ -118,21 +195,16 @@ export async function backupRestoreCommand( cwd: tempDir, }); const { manifest } = verified; - const restoredAssets: BackupRestoreResult["restoredAssets"] = []; - for (const asset of manifest.assets) { + const restoredAssets = await resolveBackupRestoreAssets(manifest.assets); + for (const asset of restoredAssets) { const extractedPath = extractedArchivePath(tempDir, asset.archivePath); await fs.access(extractedPath); - restoredAssets.push({ - kind: asset.kind, - sourcePath: asset.sourcePath, - archivePath: asset.archivePath, - status: dryRun ? "planned" : "restored", - }); if (!dryRun) { await replacePathFromExtracted({ extractedPath, targetPath: asset.sourcePath, }); + asset.status = "restored"; } }