mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
fix: exclude plugin dependencies from backups
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user