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

@@ -10,7 +10,7 @@ import type {
import { registerInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import { resolveUserPath } from "../utils.js";
import { registerPluginCommand } from "./commands.js";
import { registerPluginCommand, validatePluginCommandDefinition } from "./commands.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
import { registerPluginInteractiveHandler } from "./interactive.js";
@@ -177,6 +177,9 @@ export type PluginRegistryParams = {
logger: PluginLogger;
coreGatewayHandlers?: GatewayRequestHandlers;
runtime: PluginRuntime;
// When true, skip writing to the global plugin command registry during register().
// Used by non-activating snapshot loads to avoid leaking commands into the running gateway.
suppressGlobalCommands?: boolean;
};
type PluginTypedHookPolicy = {
@@ -615,19 +618,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
return;
}
// Register with the plugin command system (validates name and checks for duplicates)
const result = registerPluginCommand(record.id, command, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${result.error}`,
// For snapshot (non-activating) loads, record the command locally without touching the
// global plugin command registry so running gateway commands stay intact.
// We still validate the command definition so diagnostics match the real activation path.
// NOTE: cross-plugin duplicate command detection is intentionally skipped here because
// snapshot registries are isolated and never write to the global command table. Conflicts
// will surface when the plugin is loaded via the normal activation path at gateway startup.
if (registryParams.suppressGlobalCommands) {
const validationError = validatePluginCommandDefinition(command);
if (validationError) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${validationError}`,
});
return;
}
} else {
const result = registerPluginCommand(record.id, command, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
return;
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${result.error}`,
});
return;
}
}
record.commands.push(name);