mirror of https://github.com/openclaw/openclaw.git
feat: add config-only backups
This commit is contained in:
parent
f15712e71d
commit
cc2ce77bd2
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue