feat: add config-only backups

This commit is contained in:
Gustavo Madeira Santana 2026-03-08 15:59:42 -04:00
parent f15712e71d
commit cc2ce77bd2
6 changed files with 130 additions and 6 deletions

View File

@ -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 <archive>` 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`.

View File

@ -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"]);

View File

@ -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,
});
});

View File

@ -106,14 +106,56 @@ async function canonicalizeExistingPath(targetPath: string): Promise<string> {
export async function resolveBackupPlanFromDisk(
params: {
includeWorkspace?: boolean;
onlyConfig?: boolean;
nowMs?: number;
} = {},
): Promise<BackupPlan> {
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[] = [];

View File

@ -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;
}
});
});

View File

@ -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<BackupCreateResult> {
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,