import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; import * as backupShared from "./backup-shared.js"; import { buildBackupArchiveRoot, encodeAbsolutePathForBackupArchive, resolveBackupPlanFromPaths, resolveBackupPlanFromDisk, } from "./backup-shared.js"; import { backupCreateCommand } from "./backup.js"; const tarCreateMock = vi.hoisted(() => vi.fn()); const backupVerifyCommandMock = vi.hoisted(() => vi.fn()); vi.mock("tar", () => ({ c: tarCreateMock, })); vi.mock("./backup-verify.js", () => ({ backupVerifyCommand: backupVerifyCommandMock, })); describe("backup commands", () => { let tempHome: TempHomeEnv; async function resetTempHome() { await fs.rm(tempHome.home, { recursive: true, force: true }); await fs.mkdir(path.join(tempHome.home, ".openclaw"), { recursive: true }); delete process.env.OPENCLAW_CONFIG_PATH; } beforeAll(async () => { tempHome = await createTempHomeEnv("openclaw-backup-test-"); }); beforeEach(async () => { await resetTempHome(); tarCreateMock.mockReset(); tarCreateMock.mockImplementation(async ({ file }: { file: string }) => { await fs.writeFile(file, "archive-bytes", "utf8"); }); backupVerifyCommandMock.mockReset(); backupVerifyCommandMock.mockResolvedValue({ ok: true, archivePath: "/tmp/fake.tar.gz", archiveRoot: "fake", createdAt: new Date().toISOString(), runtimeVersion: "test", assetCount: 1, entryCount: 2, }); }); afterEach(async () => { vi.restoreAllMocks(); }); afterAll(async () => { await tempHome.restore(); }); function createRuntime(): RuntimeEnv { return { log: vi.fn(), error: vi.fn(), exit: vi.fn(), } satisfies RuntimeEnv; } async function withInvalidWorkspaceBackupConfig(fn: (runtime: RuntimeEnv) => Promise) { const stateDir = path.join(tempHome.home, ".openclaw"); const configPath = path.join(tempHome.home, "custom-config.json"); process.env.OPENCLAW_CONFIG_PATH = configPath; await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); const runtime = createRuntime(); try { return await fn(runtime); } finally { delete process.env.OPENCLAW_CONFIG_PATH; } } function expectWorkspaceCoveredByState( plan: Awaited>, ) { expect(plan.included).toHaveLength(1); expect(plan.included[0]?.kind).toBe("state"); expect(plan.skipped).toEqual( expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]), ); } it("collapses default config, credentials, and workspace into the state backup root", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); const oauthDir = path.join(stateDir, "credentials"); const workspaceDir = path.join(stateDir, "workspace"); await fs.writeFile(configPath, JSON.stringify({}), "utf8"); await fs.mkdir(oauthDir, { recursive: true }); await fs.writeFile(path.join(oauthDir, "oauth.json"), "{}", "utf8"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); const plan = await resolveBackupPlanFromPaths({ stateDir, configPath, oauthDir, workspaceDirs: [workspaceDir], includeWorkspace: true, configInsideState: true, oauthInsideState: true, nowMs: 123, }); expectWorkspaceCoveredByState(plan); }); it("orders coverage checks by canonical path so symlinked workspaces do not duplicate state", async () => { if (process.platform === "win32") { return; } const stateDir = path.join(tempHome.home, ".openclaw"); const workspaceDir = path.join(stateDir, "workspace"); const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-link-")); const workspaceLink = path.join(symlinkDir, "ws-link"); try { await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); await fs.symlink(workspaceDir, workspaceLink); const plan = await resolveBackupPlanFromPaths({ stateDir, configPath: path.join(stateDir, "openclaw.json"), oauthDir: path.join(stateDir, "credentials"), workspaceDirs: [workspaceLink], includeWorkspace: true, configInsideState: true, oauthInsideState: true, nowMs: 123, }); expectWorkspaceCoveredByState(plan); } finally { await fs.rm(symlinkDir, { recursive: true, force: true }); } }); it("creates an archive with a manifest and external workspace payload", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); const configPath = path.join(tempHome.home, "custom-config.json"); const backupDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backups-")); let capturedManifest: { assets: Array<{ kind: string; archivePath: string }>; } | null = null; let capturedEntryPaths: string[] = []; let capturedOnWriteEntry: ((entry: { path: string }) => void) | null = null; try { process.env.OPENCLAW_CONFIG_PATH = configPath; await fs.writeFile( configPath, JSON.stringify({ agents: { defaults: { workspace: externalWorkspace, }, }, }), "utf8", ); await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); await fs.writeFile(path.join(externalWorkspace, "SOUL.md"), "# external\n", "utf8"); const runtime = createRuntime(); const nowMs = Date.UTC(2026, 2, 9, 0, 0, 0); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath, oauthDir: path.join(stateDir, "credentials"), workspaceDirs: [externalWorkspace], includeWorkspace: true, configInsideState: false, oauthInsideState: true, nowMs, }), ); tarCreateMock.mockImplementationOnce( async ( options: { file: string; onWriteEntry?: (entry: { path: string }) => void }, entryPaths: string[], ) => { capturedManifest = JSON.parse(await fs.readFile(entryPaths[0], "utf8")) as { assets: Array<{ kind: string; archivePath: string }>; }; capturedEntryPaths = entryPaths; capturedOnWriteEntry = options.onWriteEntry ?? null; await fs.writeFile(options.file, "archive-bytes", "utf8"); }, ); const result = await backupCreateCommand(runtime, { output: backupDir, includeWorkspace: true, nowMs, }); expect(result.archivePath).toBe( path.join(backupDir, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), ); expect(capturedManifest).not.toBeNull(); expect(capturedOnWriteEntry).not.toBeNull(); const manifest = capturedManifest as unknown as { assets: Array<{ kind: string; archivePath: string }>; }; const onWriteEntry = capturedOnWriteEntry as unknown as (entry: { path: string }) => void; expect(manifest.assets).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "state" }), expect.objectContaining({ kind: "config" }), expect.objectContaining({ kind: "workspace" }), ]), ); const stateAsset = result.assets.find((asset) => asset.kind === "state"); const workspaceAsset = result.assets.find((asset) => asset.kind === "workspace"); expect(stateAsset).toBeDefined(); expect(workspaceAsset).toBeDefined(); expect(capturedEntryPaths).toHaveLength(result.assets.length + 1); const manifestPath = capturedEntryPaths[0]; const remappedManifestEntry = { path: manifestPath }; onWriteEntry(remappedManifestEntry); expect(remappedManifestEntry.path).toBe( path.posix.join(buildBackupArchiveRoot(nowMs), "manifest.json"), ); const remappedStateEntry = { path: stateAsset!.sourcePath }; onWriteEntry(remappedStateEntry); expect(remappedStateEntry.path).toBe( path.posix.join( buildBackupArchiveRoot(nowMs), "payload", encodeAbsolutePathForBackupArchive(stateAsset!.sourcePath), ), ); const remappedWorkspaceEntry = { path: workspaceAsset!.sourcePath }; onWriteEntry(remappedWorkspaceEntry); expect(remappedWorkspaceEntry.path).toBe( path.posix.join( buildBackupArchiveRoot(nowMs), "payload", encodeAbsolutePathForBackupArchive(workspaceAsset!.sourcePath), ), ); } finally { delete process.env.OPENCLAW_CONFIG_PATH; await fs.rm(externalWorkspace, { recursive: true, force: true }); await fs.rm(backupDir, { recursive: true, force: true }); } }); it("optionally verifies the archive after writing it", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const archiveDir = await fs.mkdtemp( path.join(os.tmpdir(), "openclaw-backup-verify-on-create-"), ); try { await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); const runtime = createRuntime(); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath: path.join(stateDir, "openclaw.json"), oauthDir: path.join(stateDir, "credentials"), includeWorkspace: false, configInsideState: true, oauthInsideState: true, nowMs: 123, }), ); const result = await backupCreateCommand(runtime, { output: archiveDir, verify: true, }); expect(result.verified).toBe(true); expect(backupVerifyCommandMock).toHaveBeenCalledWith( expect.objectContaining({ log: expect.any(Function) }), expect.objectContaining({ archive: result.archivePath, json: false }), ); } finally { await fs.rm(archiveDir, { recursive: true, force: true }); } }); it("rejects output paths that would be created inside a backed-up directory", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); const runtime = createRuntime(); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath: path.join(stateDir, "openclaw.json"), oauthDir: path.join(stateDir, "credentials"), includeWorkspace: false, configInsideState: true, oauthInsideState: true, nowMs: 123, }), ); await expect( backupCreateCommand(runtime, { output: path.join(stateDir, "backups"), }), ).rejects.toThrow(/must not be written inside a source path/i); }); it("rejects symlinked output paths even when intermediate directories do not exist yet", async () => { if (process.platform === "win32") { return; } const stateDir = path.join(tempHome.home, ".openclaw"); const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-link-")); const symlinkPath = path.join(symlinkDir, "linked-state"); try { await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.symlink(stateDir, symlinkPath); const runtime = createRuntime(); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath: path.join(stateDir, "openclaw.json"), oauthDir: path.join(stateDir, "credentials"), includeWorkspace: false, configInsideState: true, oauthInsideState: true, nowMs: 123, }), ); await expect( backupCreateCommand(runtime, { output: path.join(symlinkPath, "new", "subdir", "backup.tar.gz"), }), ).rejects.toThrow(/must not be written inside a source path/i); } finally { await fs.rm(symlinkDir, { recursive: true, force: true }); } }); it("falls back to the home directory when cwd is inside a backed-up source tree", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const workspaceDir = path.join(stateDir, "workspace"); await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); vi.spyOn(process, "cwd").mockReturnValue(workspaceDir); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath: path.join(stateDir, "openclaw.json"), oauthDir: path.join(stateDir, "credentials"), workspaceDirs: [workspaceDir], includeWorkspace: true, configInsideState: true, oauthInsideState: true, nowMs: Date.UTC(2026, 2, 9, 1, 2, 3), }), ); const runtime = createRuntime(); const nowMs = Date.UTC(2026, 2, 9, 1, 2, 3); const result = await backupCreateCommand(runtime, { nowMs }); expect(result.archivePath).toBe( path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), ); await fs.rm(result.archivePath, { force: true }); }); it("falls back to the home directory when cwd is a symlink into a backed-up source tree", async () => { if (process.platform === "win32") { return; } const stateDir = path.join(tempHome.home, ".openclaw"); const workspaceDir = path.join(stateDir, "workspace"); const linkParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cwd-link-")); const workspaceLink = path.join(linkParent, "workspace-link"); try { await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); await fs.symlink(workspaceDir, workspaceLink); vi.spyOn(process, "cwd").mockReturnValue(workspaceLink); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath: path.join(stateDir, "openclaw.json"), oauthDir: path.join(stateDir, "credentials"), workspaceDirs: [workspaceDir], includeWorkspace: true, configInsideState: true, oauthInsideState: true, nowMs: Date.UTC(2026, 2, 9, 1, 3, 4), }), ); const runtime = createRuntime(); const nowMs = Date.UTC(2026, 2, 9, 1, 3, 4); const result = await backupCreateCommand(runtime, { nowMs }); expect(result.archivePath).toBe( path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), ); await fs.rm(result.archivePath, { force: true }); } finally { await fs.rm(linkParent, { recursive: true, force: true }); } }); it("allows dry-run preview even when the target archive already exists", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const existingArchive = path.join(tempHome.home, "existing-backup.tar.gz"); await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); await fs.writeFile(existingArchive, "already here", "utf8"); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath: path.join(stateDir, "openclaw.json"), oauthDir: path.join(stateDir, "credentials"), includeWorkspace: false, configInsideState: true, oauthInsideState: true, nowMs: 123, }), ); const runtime = createRuntime(); const result = await backupCreateCommand(runtime, { output: existingArchive, dryRun: true, }); expect(result.dryRun).toBe(true); expect(result.verified).toBe(false); expect(result.archivePath).toBe(existingArchive); expect(await fs.readFile(existingArchive, "utf8")).toBe("already here"); }); it("fails fast when config is invalid and workspace backup is enabled", async () => { await withInvalidWorkspaceBackupConfig(async (runtime) => { await expect(backupCreateCommand(runtime, { dryRun: true })).rejects.toThrow( /--no-include-workspace/i, ); }); }); it("allows explicit partial backups when config is invalid", async () => { await withInvalidWorkspaceBackupConfig(async (runtime) => { const result = await backupCreateCommand(runtime, { dryRun: true, includeWorkspace: false, }); expect(result.includeWorkspace).toBe(false); expect(result.assets.some((asset) => asset.kind === "workspace")).toBe(false); }); }); it("backs up only the active config file when --only-config is requested", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true }); await fs.writeFile(configPath, JSON.stringify({ theme: "config-only" }), "utf8"); await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8"); vi.spyOn(backupShared, "resolveBackupPlanFromDisk").mockResolvedValue( await resolveBackupPlanFromPaths({ stateDir, configPath, oauthDir: path.join(stateDir, "credentials"), includeWorkspace: false, onlyConfig: true, configInsideState: true, oauthInsideState: true, nowMs: 123, }), ); const runtime = createRuntime(); const result = await backupCreateCommand(runtime, { dryRun: true, onlyConfig: true, }); expect(result.onlyConfig).toBe(true); expect(result.includeWorkspace).toBe(false); expect(result.assets).toHaveLength(1); expect(result.assets[0]?.kind).toBe("config"); }); it("allows config-only backups even when the config file is invalid", async () => { const configPath = path.join(tempHome.home, "custom-config.json"); process.env.OPENCLAW_CONFIG_PATH = configPath; await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); const runtime = createRuntime(); try { const result = await backupCreateCommand(runtime, { dryRun: true, onlyConfig: true, }); expect(result.assets).toHaveLength(1); expect(result.assets[0]?.kind).toBe("config"); } finally { delete process.env.OPENCLAW_CONFIG_PATH; } }); });