mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 00:42:24 +00:00
fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts (#46763)
* fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts Onboarding and channel-add flows previously loaded the full plugin registry, which caused OOM crashes on memory-constrained hosts. This patch introduces scoped, non-activating plugin registry snapshots that load only the selected channel plugin without replacing the running gateway's global state. Key changes: - Add onlyPluginIds and activate options to loadOpenClawPlugins for scoped loads - Add suppressGlobalCommands to plugin registry to avoid leaking commands - Replace full registry reloads in onboarding with per-channel scoped snapshots - Validate command definitions in snapshot loads without writing global registry - Preload configured external plugins via scoped discovery during onboarding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): add return type annotation to hoisted mock to resolve TS2322 * fix(plugins): enforce cache:false invariant for non-activating snapshot loads * Channels: preserve lazy scoped snapshot import after rebase * Onboarding: scope channel snapshots by plugin id * Catalog: trust manifest ids for channel plugin mapping * Onboarding: preserve scoped setup channel loading * Onboarding: restore built-in adapter fallback --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -14,15 +14,19 @@ async function importFreshPluginTestModules() {
|
||||
vi.unmock("./hooks.js");
|
||||
vi.unmock("./loader.js");
|
||||
vi.unmock("jiti");
|
||||
const [loader, hookRunnerGlobal, hooks] = await Promise.all([
|
||||
const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([
|
||||
import("./loader.js"),
|
||||
import("./hook-runner-global.js"),
|
||||
import("./hooks.js"),
|
||||
import("./runtime.js"),
|
||||
import("./registry.js"),
|
||||
]);
|
||||
return {
|
||||
...loader,
|
||||
...hookRunnerGlobal,
|
||||
...hooks,
|
||||
...runtime,
|
||||
...registry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,9 +34,13 @@ const {
|
||||
__testing,
|
||||
clearPluginLoaderCache,
|
||||
createHookRunner,
|
||||
createEmptyPluginRegistry,
|
||||
getActivePluginRegistry,
|
||||
getActivePluginRegistryKey,
|
||||
getGlobalHookRunner,
|
||||
loadOpenClawPlugins,
|
||||
resetGlobalHookRunner,
|
||||
setActivePluginRegistry,
|
||||
} = await importFreshPluginTestModules();
|
||||
|
||||
type TempPlugin = { dir: string; file: string; id: string };
|
||||
@@ -580,6 +588,112 @@ describe("loadOpenClawPlugins", () => {
|
||||
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
|
||||
});
|
||||
|
||||
it("limits imports to the requested plugin ids", () => {
|
||||
useNoBundledPlugins();
|
||||
const allowed = writePlugin({
|
||||
id: "allowed",
|
||||
filename: "allowed.cjs",
|
||||
body: `module.exports = { id: "allowed", register() {} };`,
|
||||
});
|
||||
const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt");
|
||||
const skipped = writePlugin({
|
||||
id: "skipped",
|
||||
filename: "skipped.cjs",
|
||||
body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8");
|
||||
module.exports = { id: "skipped", register() { throw new Error("skipped plugin should not load"); } };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [allowed.file, skipped.file] },
|
||||
allow: ["allowed", "skipped"],
|
||||
},
|
||||
},
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
|
||||
expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed"]);
|
||||
expect(fs.existsSync(skippedMarker)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps scoped plugin loads in a separate cache entry", () => {
|
||||
useNoBundledPlugins();
|
||||
const allowed = writePlugin({
|
||||
id: "allowed",
|
||||
filename: "allowed.cjs",
|
||||
body: `module.exports = { id: "allowed", register() {} };`,
|
||||
});
|
||||
const extra = writePlugin({
|
||||
id: "extra",
|
||||
filename: "extra.cjs",
|
||||
body: `module.exports = { id: "extra", register() {} };`,
|
||||
});
|
||||
const options = {
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [allowed.file, extra.file] },
|
||||
allow: ["allowed", "extra"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const full = loadOpenClawPlugins(options);
|
||||
const scoped = loadOpenClawPlugins({
|
||||
...options,
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
const scopedAgain = loadOpenClawPlugins({
|
||||
...options,
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
|
||||
expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual(["allowed", "extra"]);
|
||||
expect(scoped).not.toBe(full);
|
||||
expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]);
|
||||
expect(scopedAgain).toBe(scoped);
|
||||
});
|
||||
|
||||
it("can load a scoped registry without replacing the active global registry", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "allowed",
|
||||
filename: "allowed.cjs",
|
||||
body: `module.exports = { id: "allowed", register() {} };`,
|
||||
});
|
||||
const previousRegistry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(previousRegistry, "existing-registry");
|
||||
resetGlobalHookRunner();
|
||||
|
||||
const scoped = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
workspaceDir: plugin.dir,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["allowed"],
|
||||
},
|
||||
},
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
|
||||
expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]);
|
||||
expect(getActivePluginRegistry()).toBe(previousRegistry);
|
||||
expect(getActivePluginRegistryKey()).toBe("existing-registry");
|
||||
expect(getGlobalHookRunner()).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when activate:false is used without cache:false", () => {
|
||||
expect(() => loadOpenClawPlugins({ activate: false })).toThrow(
|
||||
"activate:false requires cache:false",
|
||||
);
|
||||
expect(() => loadOpenClawPlugins({ activate: false, cache: true })).toThrow(
|
||||
"activate:false requires cache:false",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-initializes global hook runner when serving registry from cache", () => {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
|
||||
Reference in New Issue
Block a user