From e11eb03182d60bf3265edc83ef70afbde39557bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 04:03:14 +0100 Subject: [PATCH] fix: exclude plugin dependencies from backups --- CHANGELOG.md | 1 + docs/cli/backup.md | 7 ++ src/infra/backup-create.test.ts | 130 +++++++++++++++++++++++++++++++- src/infra/backup-create.ts | 21 ++++++ 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a124363bf..a1c796b9a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Backup: skip installed plugin `extensions/*/node_modules` dependency trees while keeping plugin manifests and source files in archives, so local backups avoid rebuildable npm payload bloat. Fixes #64144. Thanks @BrilliantWang. - Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers..api` owner, so multi-GPU Ollama setups can dedicate embeddings to providers such as `ollama-5080` without losing the Ollama adapter or local auth semantics. Fixes #73150. Thanks @oneandrewwang. - CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana. - CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva. diff --git a/docs/cli/backup.md b/docs/cli/backup.md index ad47b2a564b..8e7e583030a 100644 --- a/docs/cli/backup.md +++ b/docs/cli/backup.md @@ -53,6 +53,13 @@ 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. +Installed plugin source and manifest files under the state directory's +`extensions/` tree are included, but their nested `node_modules/` dependency +trees are skipped. Those dependencies are rebuildable install artifacts; after +restoring an archive, use `openclaw plugins update ` or reinstall the plugin +with `openclaw plugins install --force` when a restored plugin reports +missing dependencies. + ## Invalid config behavior `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. diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index bc375270227..41d5080bcf9 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it } from "vitest"; -import { formatBackupCreateSummary, type BackupCreateResult } from "./backup-create.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { describe, expect, it, vi } from "vitest"; +import { backupVerifyCommand } from "../commands/backup-verify.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { + buildExtensionsNodeModulesFilter, + createBackupArchive, + formatBackupCreateSummary, + type BackupCreateResult, +} from "./backup-create.js"; function makeResult(overrides: Partial = {}): BackupCreateResult { return { @@ -16,6 +27,27 @@ function makeResult(overrides: Partial = {}): BackupCreateRe }; } +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +async function listArchiveEntries(archivePath: string): Promise { + const entries: string[] = []; + await tar.t({ + file: archivePath, + gzip: true, + onentry: (entry) => { + entries.push(entry.path); + entry.resume(); + }, + }); + return entries; +} + describe("formatBackupCreateSummary", () => { const backupArchiveLine = "Backup archive: /tmp/openclaw-backup.tar.gz"; @@ -83,3 +115,97 @@ describe("formatBackupCreateSummary", () => { expect(formatBackupCreateSummary(result)).toEqual(expected); }); }); + +describe("buildExtensionsNodeModulesFilter", () => { + it("excludes dependency trees only under state extensions", () => { + const filter = buildExtensionsNodeModulesFilter("/state/"); + + expect(filter("/state/extensions/demo/openclaw.plugin.json")).toBe(true); + expect(filter("/state/extensions/demo/src/index.js")).toBe(true); + expect(filter("/state/extensions/demo/node_modules/dep/index.js")).toBe(false); + expect(filter("/state/extensions/demo/vendor/node_modules/dep/index.js")).toBe(false); + expect(filter("/state/node_modules/dep/index.js")).toBe(true); + expect(filter("/state/extensions-node_modules/demo/index.js")).toBe(true); + }); + + it("normalizes Windows path separators", () => { + const filter = buildExtensionsNodeModulesFilter("C:\\Users\\me\\.openclaw\\"); + + expect(filter(String.raw`C:\Users\me\.openclaw\extensions\demo\index.js`)).toBe(true); + expect( + filter(String.raw`C:\Users\me\.openclaw\extensions\demo\node_modules\dep\index.js`), + ).toBe(false); + }); +}); + +describe("createBackupArchive", () => { + it("omits installed plugin node_modules from the real archive while keeping plugin files", async () => { + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-plugin-deps-")); + + try { + const stateDir = path.join(root, "state"); + const outputDir = path.join(root, "backups"); + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); + + await fs.mkdir(path.join(stateDir, "extensions", "demo", "node_modules", "dep"), { + recursive: true, + }); + await fs.mkdir(path.join(stateDir, "extensions", "demo", "src"), { recursive: true }); + await fs.mkdir(path.join(stateDir, "node_modules", "root-dep"), { recursive: true }); + await fs.writeFile(process.env.OPENCLAW_CONFIG_PATH, "{}\n", "utf8"); + await fs.writeFile( + path.join(stateDir, "extensions", "demo", "openclaw.plugin.json"), + '{"id":"demo"}\n', + "utf8", + ); + await fs.writeFile( + path.join(stateDir, "extensions", "demo", "src", "index.js"), + "export default {}\n", + "utf8", + ); + await fs.writeFile( + path.join(stateDir, "extensions", "demo", "node_modules", "dep", "index.js"), + "module.exports = {}\n", + "utf8", + ); + await fs.writeFile( + path.join(stateDir, "node_modules", "root-dep", "index.js"), + "module.exports = {}\n", + "utf8", + ); + await fs.mkdir(outputDir, { recursive: true }); + + const result = await createBackupArchive({ + output: outputDir, + includeWorkspace: false, + nowMs: Date.UTC(2026, 3, 28, 12, 0, 0), + }); + const entries = await listArchiveEntries(result.archivePath); + + expect( + entries.some((entry) => entry.endsWith("/state/extensions/demo/openclaw.plugin.json")), + ).toBe(true); + expect(entries.some((entry) => entry.endsWith("/state/extensions/demo/src/index.js"))).toBe( + true, + ); + expect(entries.some((entry) => entry.endsWith("/state/node_modules/root-dep/index.js"))).toBe( + true, + ); + expect(entries.some((entry) => entry.includes("/state/extensions/demo/node_modules/"))).toBe( + false, + ); + + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + await expect( + backupVerifyCommand(runtime, { archive: result.archivePath }), + ).resolves.toMatchObject({ ok: true }); + } finally { + restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir); + restoreEnvValue("OPENCLAW_CONFIG_PATH", previousConfigPath); + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 54af4c553a7..696b0f9bc3f 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -277,6 +277,24 @@ function remapArchiveEntryPath(params: { return buildBackupArchivePath(params.archiveRoot, normalizedEntry); } +function normalizeBackupFilterPath(value: string): string { + return value.replaceAll("\\", "/").replace(/\/+$/u, ""); +} + +export function buildExtensionsNodeModulesFilter(stateDir: string): (filePath: string) => boolean { + const normalizedStateDir = normalizeBackupFilterPath(stateDir); + const extensionsPrefix = `${normalizedStateDir}/extensions/`; + + return (filePath: string): boolean => { + const normalizedFilePath = normalizeBackupFilterPath(filePath); + if (!normalizedFilePath.startsWith(extensionsPrefix)) { + return true; + } + + return !normalizedFilePath.slice(extensionsPrefix.length).split("/").includes("node_modules"); + }; +} + export async function createBackupArchive( opts: BackupCreateOptions = {}, ): Promise { @@ -351,9 +369,12 @@ export async function createBackupArchive( await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); const tar = await loadTarRuntime(); + const stateAsset = result.assets.find((asset) => asset.kind === "state"); + const filter = stateAsset ? buildExtensionsNodeModulesFilter(stateAsset.sourcePath) : undefined; await tar.c( { file: tempArchivePath, + ...(filter ? { filter } : {}), gzip: true, portable: true, preservePaths: true,