fix: exclude plugin dependencies from backups

This commit is contained in:
Peter Steinberger
2026-04-28 04:03:14 +01:00
parent 719ec4f292
commit e11eb03182
4 changed files with 157 additions and 2 deletions

View File

@@ -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.<id>.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.

View File

@@ -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 <id>` or reinstall the plugin
with `openclaw plugins install <spec> --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.

View File

@@ -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> = {}): BackupCreateResult {
return {
@@ -16,6 +27,27 @@ function makeResult(overrides: Partial<BackupCreateResult> = {}): 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<string[]> {
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 });
}
});
});

View File

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