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:
Mason
2026-03-16 07:52:08 +08:00
committed by GitHub
parent a058bf918d
commit f4cc93dc7d
15 changed files with 1127 additions and 99 deletions

View File

@@ -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({