diff --git a/docs/cli/backup.md b/docs/cli/backup.md index 4a45cd1b789..a39b0fefac6 100644 --- a/docs/cli/backup.md +++ b/docs/cli/backup.md @@ -16,6 +16,7 @@ openclaw backup create --output ~/Backups openclaw backup create --dry-run --json openclaw backup create --verify openclaw backup create --no-include-workspace +openclaw backup create --only-config openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz ``` @@ -28,6 +29,7 @@ openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz - Output paths inside the source state/workspace trees are rejected to avoid self-inclusion. - `openclaw backup verify ` validates that the archive contains exactly one root manifest, rejects traversal-style archive paths, and checks that every manifest-declared payload exists in the tarball. - `openclaw backup create --verify` runs that validation immediately after writing the archive. +- `openclaw backup create --only-config` backs up just the active JSON config file. ## What gets backed up @@ -38,6 +40,8 @@ openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz - The OAuth / credentials directory - Workspace directories discovered from the current config, unless you pass `--no-include-workspace` +If you use `--only-config`, OpenClaw skips state, credentials, and workspace discovery and archives only the active config file path. + OpenClaw canonicalizes paths before building the archive. If config, credentials, or a workspace already live inside the state directory, they are not duplicated as separate top-level backup sources. Missing paths are skipped. The archive payload stores file contents from those source trees, and the embedded `manifest.json` records the resolved absolute source paths plus the archive layout used for each asset. @@ -54,6 +58,8 @@ openclaw backup create --no-include-workspace That keeps state, config, and credentials in scope while skipping workspace discovery entirely. +If you only need a copy of the config file itself, `--only-config` also works when the config is malformed because it does not rely on parsing the config for workspace discovery. + ## Size and performance OpenClaw does not enforce a built-in maximum backup size or per-file size limit. @@ -66,3 +72,5 @@ Practical limits come from the local machine and destination filesystem: - Filesystem behavior at the destination path. OpenClaw prefers a no-overwrite hard-link publish step and falls back to exclusive copy when hard links are unsupported Large workspaces are usually the main driver of archive size. If you want a smaller or faster backup, use `--no-include-workspace`. + +For the smallest archive, use `--only-config`. diff --git a/src/cli/program/register.backup.test.ts b/src/cli/program/register.backup.test.ts index 0e45da0020c..b0f62cb97bc 100644 --- a/src/cli/program/register.backup.test.ts +++ b/src/cli/program/register.backup.test.ts @@ -51,6 +51,7 @@ describe("registerBackupCommand", () => { json: true, dryRun: true, verify: false, + onlyConfig: false, includeWorkspace: true, }), ); @@ -78,6 +79,17 @@ describe("registerBackupCommand", () => { ); }); + it("forwards --only-config to backup create", async () => { + await runCli(["backup", "create", "--only-config"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + onlyConfig: true, + }), + ); + }); + it("runs backup verify with forwarded options", async () => { await runCli(["backup", "verify", "/tmp/openclaw-backup.tar.gz", "--json"]); diff --git a/src/cli/program/register.backup.ts b/src/cli/program/register.backup.ts index 8c9e17be117..fc928f0ff3a 100644 --- a/src/cli/program/register.backup.ts +++ b/src/cli/program/register.backup.ts @@ -24,6 +24,7 @@ export function registerBackupCommand(program: Command) { .option("--json", "Output JSON", false) .option("--dry-run", "Print the backup plan without writing the archive", false) .option("--verify", "Verify the archive after writing it", false) + .option("--only-config", "Back up only the active JSON config file", false) .option("--no-include-workspace", "Exclude workspace directories from the backup") .addHelpText( "after", @@ -46,6 +47,7 @@ export function registerBackupCommand(program: Command) { "openclaw backup create --no-include-workspace", "Back up state/config without agent workspace files.", ], + ["openclaw backup create --only-config", "Back up only the active JSON config file."], ])}`, ) .action(async (opts) => { @@ -55,6 +57,7 @@ export function registerBackupCommand(program: Command) { json: Boolean(opts.json), dryRun: Boolean(opts.dryRun), verify: Boolean(opts.verify), + onlyConfig: Boolean(opts.onlyConfig), includeWorkspace: opts.includeWorkspace as boolean, }); }); diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts index dfd747ffe0d..b4b6961bbaa 100644 --- a/src/commands/backup-shared.ts +++ b/src/commands/backup-shared.ts @@ -106,14 +106,56 @@ async function canonicalizeExistingPath(targetPath: string): Promise { export async function resolveBackupPlanFromDisk( params: { includeWorkspace?: boolean; + onlyConfig?: boolean; nowMs?: number; } = {}, ): Promise { const includeWorkspace = params.includeWorkspace ?? true; - const configSnapshot = await readConfigFileSnapshot(); + const onlyConfig = params.onlyConfig ?? false; const stateDir = resolveStateDir(); const configPath = resolveConfigPath(); const oauthDir = resolveOAuthDir(); + const archiveRoot = buildBackupArchiveRoot(params.nowMs); + + if (onlyConfig) { + const resolvedConfigPath = path.resolve(configPath); + if (!(await pathExists(resolvedConfigPath))) { + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: [], + included: [], + skipped: [ + { + kind: "config", + sourcePath: resolvedConfigPath, + displayPath: shortenHomePath(resolvedConfigPath), + reason: "missing", + }, + ], + }; + } + + const canonicalConfigPath = await canonicalizeExistingPath(resolvedConfigPath); + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: [], + included: [ + { + kind: "config", + sourcePath: canonicalConfigPath, + displayPath: shortenHomePath(canonicalConfigPath), + archivePath: buildBackupArchivePath(archiveRoot, canonicalConfigPath), + }, + ], + skipped: [], + }; + } + + const configSnapshot = await readConfigFileSnapshot(); 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.`, @@ -165,8 +207,6 @@ export async function resolveBackupPlanFromDisk( seenCanonicalPaths.add(candidate.canonicalPath); uniqueCandidates.push(candidate); } - const archiveRoot = buildBackupArchiveRoot(params.nowMs); - const included: BackupAsset[] = []; const skipped: SkippedBackupAsset[] = []; diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 07e3af80184..6d146221505 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -348,4 +348,53 @@ describe("backup commands", () => { delete process.env.OPENCLAW_CONFIG_PATH; } }); + + 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"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + 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 = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + 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; + } + }); }); diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 52f2b3553b1..22c426467d7 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -21,6 +21,7 @@ export type BackupCreateOptions = { output?: string; dryRun?: boolean; includeWorkspace?: boolean; + onlyConfig?: boolean; verify?: boolean; json?: boolean; nowMs?: number; @@ -41,6 +42,7 @@ type BackupManifest = { nodeVersion: string; options: { includeWorkspace: boolean; + onlyConfig?: boolean; }; paths: { stateDir: string; @@ -63,6 +65,7 @@ export type BackupCreateResult = { archivePath: string; dryRun: boolean; includeWorkspace: boolean; + onlyConfig: boolean; verified: boolean; assets: BackupAsset[]; skipped: Array<{ @@ -189,6 +192,7 @@ function buildManifest(params: { createdAt: string; archiveRoot: string; includeWorkspace: boolean; + onlyConfig: boolean; assets: BackupAsset[]; skipped: BackupCreateResult["skipped"]; stateDir: string; @@ -205,6 +209,7 @@ function buildManifest(params: { nodeVersion: process.version, options: { includeWorkspace: params.includeWorkspace, + onlyConfig: params.onlyConfig, }, paths: { stateDir: params.stateDir, @@ -271,8 +276,9 @@ export async function backupCreateCommand( ): Promise { const nowMs = opts.nowMs ?? Date.now(); const archiveRoot = buildBackupArchiveRoot(nowMs); - const includeWorkspace = opts.includeWorkspace ?? true; - const plan = await resolveBackupPlanFromDisk({ includeWorkspace, nowMs }); + const onlyConfig = Boolean(opts.onlyConfig); + const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); + const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); const outputPath = await resolveOutputPath({ output: opts.output, nowMs, @@ -281,7 +287,11 @@ export async function backupCreateCommand( }); if (plan.included.length === 0) { - throw new Error("No local OpenClaw state was found to back up."); + throw new Error( + onlyConfig + ? "No OpenClaw config file was found to back up." + : "No local OpenClaw state was found to back up.", + ); } const canonicalOutputPath = await canonicalizePathForContainment(outputPath); @@ -305,6 +315,7 @@ export async function backupCreateCommand( archivePath: outputPath, dryRun: Boolean(opts.dryRun), includeWorkspace, + onlyConfig, verified: false, assets: plan.included, skipped: plan.skipped, @@ -320,6 +331,7 @@ export async function backupCreateCommand( createdAt, archiveRoot, includeWorkspace, + onlyConfig, assets: result.assets, skipped: result.skipped, stateDir: plan.stateDir,