Skip to content

Commit 0ecfd37

Browse files
feat: add local backup CLI (#40163)
Merged via squash. Prepared head SHA: ed46625 Co-authored-by: shichangs <[email protected]> Co-authored-by: gumadeiras <[email protected]> Reviewed-by: @gumadeiras
1 parent a075bab commit 0ecfd37

22 files changed

+2256
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Docs: https://docs.openclaw.ai
1212
- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
1313
- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.
1414
- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.
15+
- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.
16+
- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.
1517

1618
### Fixes
1719

docs/cli/backup.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
summary: "CLI reference for `openclaw backup` (create local backup archives)"
3+
read_when:
4+
- You want a first-class backup archive for local OpenClaw state
5+
- You want to preview which paths would be included before reset or uninstall
6+
title: "backup"
7+
---
8+
9+
# `openclaw backup`
10+
11+
Create a local backup archive for OpenClaw state, config, credentials, sessions, and optionally workspaces.
12+
13+
```bash
14+
openclaw backup create
15+
openclaw backup create --output ~/Backups
16+
openclaw backup create --dry-run --json
17+
openclaw backup create --verify
18+
openclaw backup create --no-include-workspace
19+
openclaw backup create --only-config
20+
openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz
21+
```
22+
23+
## Notes
24+
25+
- The archive includes a `manifest.json` file with the resolved source paths and archive layout.
26+
- Default output is a timestamped `.tar.gz` archive in the current working directory.
27+
- If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location.
28+
- Existing archive files are never overwritten.
29+
- Output paths inside the source state/workspace trees are rejected to avoid self-inclusion.
30+
- `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.
31+
- `openclaw backup create --verify` runs that validation immediately after writing the archive.
32+
- `openclaw backup create --only-config` backs up just the active JSON config file.
33+
34+
## What gets backed up
35+
36+
`openclaw backup create` plans backup sources from your local OpenClaw install:
37+
38+
- The state directory returned by OpenClaw's local state resolver, usually `~/.openclaw`
39+
- The active config file path
40+
- The OAuth / credentials directory
41+
- Workspace directories discovered from the current config, unless you pass `--no-include-workspace`
42+
43+
If you use `--only-config`, OpenClaw skips state, credentials, and workspace discovery and archives only the active config file path.
44+
45+
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.
46+
47+
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.
48+
49+
## Invalid config behavior
50+
51+
`openclaw backup` intentionally bypasses the normal config preflight so it can still help during recovery. Because workspace discovery depends on a valid config, `openclaw backup create` now fails fast when the config file exists but is invalid and workspace backup is still enabled.
52+
53+
If you still want a partial backup in that situation, rerun:
54+
55+
```bash
56+
openclaw backup create --no-include-workspace
57+
```
58+
59+
That keeps state, config, and credentials in scope while skipping workspace discovery entirely.
60+
61+
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.
62+
63+
## Size and performance
64+
65+
OpenClaw does not enforce a built-in maximum backup size or per-file size limit.
66+
67+
Practical limits come from the local machine and destination filesystem:
68+
69+
- Available space for the temporary archive write plus the final archive
70+
- Time to walk large workspace trees and compress them into a `.tar.gz`
71+
- Time to rescan the archive if you use `openclaw backup create --verify` or run `openclaw backup verify`
72+
- 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
73+
74+
Large workspaces are usually the main driver of archive size. If you want a smaller or faster backup, use `--no-include-workspace`.
75+
76+
For the smallest archive, use `--only-config`.

docs/cli/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ This page describes the current CLI behavior. If commands change, update this do
1919
- [`completion`](/cli/completion)
2020
- [`doctor`](/cli/doctor)
2121
- [`dashboard`](/cli/dashboard)
22+
- [`backup`](/cli/backup)
2223
- [`reset`](/cli/reset)
2324
- [`uninstall`](/cli/uninstall)
2425
- [`update`](/cli/update)
@@ -103,6 +104,9 @@ openclaw [--dev] [--profile <name>] <command>
103104
completion
104105
doctor
105106
dashboard
107+
backup
108+
create
109+
verify
106110
security
107111
audit
108112
secrets

docs/cli/reset.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ title: "reset"
1111
Reset local config/state (keeps the CLI installed).
1212

1313
```bash
14+
openclaw backup create
1415
openclaw reset
1516
openclaw reset --dry-run
1617
openclaw reset --scope config+creds+sessions --yes --non-interactive
1718
```
19+
20+
Run `openclaw backup create` first if you want a restorable snapshot before removing local state.

docs/cli/uninstall.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ title: "uninstall"
1111
Uninstall the gateway service + local data (CLI remains).
1212

1313
```bash
14+
openclaw backup create
1415
openclaw uninstall
1516
openclaw uninstall --all --yes
1617
openclaw uninstall --dry-run
1718
```
19+
20+
Run `openclaw backup create` first if you want a restorable snapshot before removing state or workspaces.

src/cli/command-secret-gateway.test.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -273,22 +273,17 @@ describe("resolveCommandSecretRefsViaGateway", () => {
273273
});
274274

275275
it("fails when configured refs remain unresolved after gateway assignments are applied", async () => {
276+
const envKey = "TALK_API_KEY_STRICT_UNRESOLVED";
276277
callGateway.mockResolvedValueOnce({
277278
assignments: [],
278279
diagnostics: [],
279280
});
280281

281-
await expect(
282-
resolveCommandSecretRefsViaGateway({
283-
config: {
284-
talk: {
285-
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
286-
},
287-
} as OpenClawConfig,
288-
commandName: "memory status",
289-
targetIds: new Set(["talk.apiKey"]),
290-
}),
291-
).rejects.toThrow(/talk\.apiKey is unresolved in the active runtime snapshot/i);
282+
await withEnvValue(envKey, undefined, async () => {
283+
await expect(resolveTalkApiKey({ envKey })).rejects.toThrow(
284+
/talk\.apiKey is unresolved in the active runtime snapshot/i,
285+
);
286+
});
292287
});
293288

294289
it("allows unresolved refs when gateway diagnostics mark the target as inactive", async () => {

src/cli/program/command-registry.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ vi.mock("./register.agent.js", () => ({
1111
},
1212
}));
1313

14+
vi.mock("./register.backup.js", () => ({
15+
registerBackupCommand: (program: Command) => {
16+
const backup = program.command("backup");
17+
backup.command("create");
18+
},
19+
}));
20+
1421
vi.mock("./register.maintenance.js", () => ({
1522
registerMaintenanceCommands: (program: Command) => {
1623
program.command("doctor");
@@ -67,6 +74,7 @@ describe("command-registry", () => {
6774
expect(names).toContain("config");
6875
expect(names).toContain("memory");
6976
expect(names).toContain("agents");
77+
expect(names).toContain("backup");
7078
expect(names).toContain("browser");
7179
expect(names).toContain("sessions");
7280
expect(names).not.toContain("agent");

src/cli/program/command-registry.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ const coreEntries: CoreCliEntry[] = [
9292
mod.registerConfigCli(program);
9393
},
9494
},
95+
{
96+
commands: [
97+
{
98+
name: "backup",
99+
description: "Create and verify local backup archives for OpenClaw state",
100+
hasSubcommands: true,
101+
},
102+
],
103+
register: async ({ program }) => {
104+
const mod = await import("./register.backup.js");
105+
mod.registerBackupCommand(program);
106+
},
107+
},
95108
{
96109
commands: [
97110
{

src/cli/program/preaction.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ describe("registerPreActionHooks", () => {
8080
function buildProgram() {
8181
const program = new Command().name("openclaw");
8282
program.command("status").action(() => {});
83+
program
84+
.command("backup")
85+
.command("create")
86+
.option("--json")
87+
.action(() => {});
8388
program.command("doctor").action(() => {});
8489
program.command("completion").action(() => {});
8590
program.command("secrets").action(() => {});
@@ -226,6 +231,15 @@ describe("registerPreActionHooks", () => {
226231
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
227232
});
228233

234+
it("bypasses config guard for backup create", async () => {
235+
await runPreAction({
236+
parseArgv: ["backup", "create"],
237+
processArgv: ["node", "openclaw", "backup", "create", "--json"],
238+
});
239+
240+
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
241+
});
242+
229243
beforeAll(() => {
230244
program = buildProgram();
231245
const hooks = (

src/cli/program/preaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
3636
"status",
3737
"health",
3838
]);
39-
const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]);
39+
const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["backup", "doctor", "completion", "secrets"]);
4040
const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]);
4141
let configGuardModulePromise: Promise<typeof import("./config-guard.js")> | undefined;
4242
let pluginRegistryModulePromise: Promise<typeof import("../plugin-registry.js")> | undefined;

0 commit comments

Comments
 (0)