From d699662083806b318a9ac87a2eb4a9ea4b094994 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 13:48:27 -0700 Subject: [PATCH] fix(backup): exclude state temp files (#122250) --- docs/cli/backup.md | 2 +- src/infra/backup-create.test.ts | 43 ++++++++++++++++++++++++++++++--- src/infra/backup-create.ts | 4 +-- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/cli/backup.md b/docs/cli/backup.md index 89df597d29a0..5570bfbf1f6e 100644 --- a/docs/cli/backup.md +++ b/docs/cli/backup.md @@ -110,7 +110,7 @@ SQLite databases under the state directory are captured with SQLite's online bac Installed plugin source and manifest files under the state directory's `extensions/` tree are included, but their nested `node_modules/` dependency trees are skipped as rebuildable install artifacts. After restoring an archive, use `openclaw plugins update ` or reinstall with `openclaw plugins install --force` if a restored plugin reports missing dependencies. -Installer-managed and rebuildable runtime roots under the state directory are also skipped: `dev/`, `git/`, `npm/`, legacy `npm-runtime/`, and `tools/`. These contain managed checkouts, package trees, and downloaded runtimes rather than authoritative user state; reinstall or update the corresponding runtime or plugin after restore. An explicitly configured config file, credentials directory, or workspace inside one of these roots remains included. +Installer-managed and rebuildable runtime roots under the state directory are also skipped: `dev/`, `git/`, `npm/`, legacy `npm-runtime/`, `tmp/`, and `tools/`. These contain managed checkouts, package trees, compiler caches, temporary files, and downloaded runtimes rather than authoritative user state; reinstall or update the corresponding runtime or plugin after restore. An explicitly configured config file, credentials directory, or workspace inside one of these roots remains included. Local edits inside a managed `dev/` checkout are developer source, not OpenClaw product state, and are not included. Commit and push those edits or copy the checkout separately before relying on a state backup. diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 6f4a4a344462..5db8e6328705 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -726,7 +726,7 @@ describe("createBackupArchive", () => { suffix, ).toBe(false); } - expect(result.skippedVolatileCount).toBe(10); + expect(result.skippedVolatileCount).toBe(9); }, ); }); @@ -2524,7 +2524,9 @@ describe("createBackupArchive", () => { await fs.mkdir(path.join(stateDir, "dev", "openclaw", "dist"), { recursive: true }); await fs.mkdir(path.join(stateDir, "developer"), { recursive: true }); await fs.mkdir(path.join(stateDir, "dev-backup"), { recursive: true }); - for (const managedRoot of ["dev", "git", "npm-runtime", "tools"]) { + await fs.mkdir(path.join(stateDir, "temporary"), { recursive: true }); + await fs.mkdir(path.join(stateDir, "tmp-data"), { recursive: true }); + for (const managedRoot of ["dev", "git", "npm-runtime", "tmp", "tools"]) { await fs.mkdir(path.join(stateDir, managedRoot, "runtime"), { recursive: true }); await fs.writeFile( path.join(stateDir, managedRoot, "runtime", "fixture.sqlite"), @@ -2589,6 +2591,8 @@ describe("createBackupArchive", () => { ); await fs.writeFile(path.join(stateDir, "developer", "keep.txt"), "keep\n", "utf8"); await fs.writeFile(path.join(stateDir, "dev-backup", "keep.txt"), "keep\n", "utf8"); + await fs.writeFile(path.join(stateDir, "temporary", "keep.txt"), "keep\n", "utf8"); + await fs.writeFile(path.join(stateDir, "tmp-data", "keep.txt"), "keep\n", "utf8"); await fs.mkdir(outputDir, { recursive: true }); const result = await createBackupArchive({ @@ -2603,7 +2607,7 @@ describe("createBackupArchive", () => { expect(entrySuffixes).toContain("/state/extensions/demo/src/index.js"); expect(entrySuffixes).toContain("/state/node_modules/root-dep/index.js"); expect(entrySuffixes).toContain("/state/node_modules/root-dep/fixture.sqlite"); - for (const managedRoot of ["dev", "git", "npm", "npm-runtime", "tools"]) { + for (const managedRoot of ["dev", "git", "npm", "npm-runtime", "tmp", "tools"]) { expect( entrySuffixes.some( (entry) => @@ -2614,6 +2618,8 @@ describe("createBackupArchive", () => { } expect(entrySuffixes).toContain("/state/developer/keep.txt"); expect(entrySuffixes).toContain("/state/dev-backup/keep.txt"); + expect(entrySuffixes).toContain("/state/temporary/keep.txt"); + expect(entrySuffixes).toContain("/state/tmp-data/keep.txt"); const pluginNodeModuleEntries = entries.filter((entry) => entry.includes("/state/extensions/demo/node_modules/"), ); @@ -2637,6 +2643,8 @@ describe("createBackupArchive", () => { async (state) => { const stateDir = state.stateDir; const workspaceDir = path.join(stateDir, "dev", "workspace"); + const tmpWorkspaceDir = path.join(stateDir, "tmp", "workspace"); + const externalTmpWorkspaceDir = state.path("tmp"); const runtimeDir = path.join(stateDir, "dev", "openclaw"); const configPath = path.join(stateDir, "git", "config", "openclaw.json"); const oauthDir = path.join(stateDir, "tools", "oauth"); @@ -2647,6 +2655,9 @@ describe("createBackupArchive", () => { state.envVars.OPENCLAW_OAUTH_DIR = oauthDir; state.applyEnv(); await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(tmpWorkspaceDir, { recursive: true }); + await fs.mkdir(externalTmpWorkspaceDir, { recursive: true }); + await fs.mkdir(path.join(stateDir, "tmp", "tsx-501"), { recursive: true }); await fs.mkdir(runtimeDir, { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(oauthDir, { recursive: true }); @@ -2655,13 +2666,32 @@ describe("createBackupArchive", () => { configPath, `${JSON.stringify({ agents: { - entries: { main: { default: true, workspace: workspaceDir } }, + entries: { + main: { default: true, workspace: workspaceDir }, + external: { workspace: externalTmpWorkspaceDir }, + worker: { workspace: tmpWorkspaceDir }, + }, }, })}\n`, "utf8", ); await fs.writeFile(path.join(oauthDir, "credentials.json"), "{}\n", "utf8"); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "durable workspace\n", "utf8"); + await fs.writeFile( + path.join(tmpWorkspaceDir, "AGENTS.md"), + "durable tmp workspace\n", + "utf8", + ); + await fs.writeFile( + path.join(externalTmpWorkspaceDir, "AGENTS.md"), + "durable external tmp workspace\n", + "utf8", + ); + await fs.writeFile( + path.join(stateDir, "tmp", "tsx-501", "cache-entry"), + "rebuildable compiler cache\n", + "utf8", + ); await fs.writeFile(path.join(runtimeDir, "package.json"), "{}\n", "utf8"); await fs.writeFile(path.join(toolRuntimeDir, "tool.bin"), "runtime\n", "utf8"); const sqlite = requireNodeSqlite(); @@ -2688,6 +2718,10 @@ describe("createBackupArchive", () => { expect( entries.some((entry) => entry.endsWith("/state/dev/workspace/workspace.sqlite")), ).toBe(true); + expect(entries.some((entry) => entry.endsWith("/state/tmp/workspace/AGENTS.md"))).toBe( + true, + ); + expect(entries.some((entry) => entry.endsWith("/tmp/AGENTS.md"))).toBe(true); expect(entries.some((entry) => entry.endsWith("/state/git/config/openclaw.json"))).toBe( true, ); @@ -2695,6 +2729,7 @@ describe("createBackupArchive", () => { true, ); expect(entries.some((entry) => entry.includes("/state/dev/openclaw/"))).toBe(false); + expect(entries.some((entry) => entry.includes("/state/tmp/tsx-501/"))).toBe(false); expect(entries.some((entry) => entry.includes("/state/tools/runtime/"))).toBe(false); const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 98c6e9bb9a9c..b7e8795fa54e 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -331,7 +331,7 @@ function normalizeBackupFilterPath(value: string): string { return value.replaceAll("\\", "/").replace(/\/+$/u, ""); } -const REINSTALLABLE_STATE_ROOTS = new Set(["dev", "git", "npm", "npm-runtime", "tools"]); +const NON_AUTHORITATIVE_STATE_ROOTS = new Set(["dev", "git", "npm", "npm-runtime", "tmp", "tools"]); function buildStateBackupFilter( stateDir: string, @@ -348,7 +348,7 @@ function buildStateBackupFilter( } const segments = normalizedFilePath.slice(statePrefix.length).split("/"); - if (REINSTALLABLE_STATE_ROOTS.has(segments[0] ?? "")) { + if (NON_AUTHORITATIVE_STATE_ROOTS.has(segments[0] ?? "")) { const resolvedFilePath = path.resolve(filePath); // Configured workspaces nested under a managed root remain authoritative // user state. Keep their ancestors traversable without admitting siblings.