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

@@ -50,6 +50,8 @@ export type PluginLoadOptions = {
runtimeOptions?: CreatePluginRuntimeOptions;
cache?: boolean;
mode?: "full" | "validate";
onlyPluginIds?: string[];
activate?: boolean;
};
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
@@ -241,6 +243,7 @@ function buildCacheKey(params: {
plugins: NormalizedPluginsConfig;
installs?: Record<string, PluginInstallRecord>;
env: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
@@ -263,11 +266,20 @@ function buildCacheKey(params: {
},
]),
);
const scopeKey = JSON.stringify(params.onlyPluginIds ?? []);
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
...params.plugins,
installs,
loadPaths,
})}`;
})}::${scopeKey}`;
}
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
if (!ids) {
return undefined;
}
const normalized = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))).toSorted();
return normalized.length > 0 ? normalized : undefined;
}
function validatePluginConfig(params: {
@@ -640,6 +652,13 @@ function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): voi
}
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
// Snapshot (non-activating) loads must disable the cache to avoid storing a registry
// whose commands were never globally registered.
if (options.activate === false && options.cache !== false) {
throw new Error(
"loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence",
);
}
const env = options.env ?? process.env;
// Test env: default-disable plugins unless explicitly configured.
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
@@ -647,24 +666,37 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const logger = options.logger ?? defaultLogger();
const validateOnly = options.mode === "validate";
const normalized = normalizePluginsConfig(cfg.plugins);
const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds);
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
const shouldActivate = options.activate !== false;
// NOTE: `activate` is intentionally excluded from the cache key. All non-activating
// (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they
// never read from or write to the cache. Including `activate` here would be misleading
// — it would imply mixed-activate caching is supported, when in practice it is not.
const cacheKey = buildCacheKey({
workspaceDir: options.workspaceDir,
plugins: normalized,
installs: cfg.plugins?.installs,
env,
onlyPluginIds,
});
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {
const cached = getCachedPluginRegistry(cacheKey);
if (cached) {
activatePluginRegistry(cached, cacheKey);
if (shouldActivate) {
activatePluginRegistry(cached, cacheKey);
}
return cached;
}
}
// Clear previously registered plugin commands before reloading
clearPluginCommands();
clearPluginInteractiveHandlers();
// Clear previously registered plugin commands before reloading.
// Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins.
if (shouldActivate) {
clearPluginCommands();
clearPluginInteractiveHandlers();
}
// Lazily initialize the runtime so startup paths that discover/skip plugins do
// not eagerly load every channel runtime dependency.
@@ -703,6 +735,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
logger,
runtime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
suppressGlobalCommands: !shouldActivate,
});
const discovery = discoverOpenClawPlugins({
@@ -725,11 +758,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
pluginsEnabled: normalized.enabled,
allow: normalized.allow,
warningCacheKey: cacheKey,
discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({
id: plugin.id,
source: plugin.source,
origin: plugin.origin,
})),
// Keep warning input scoped as well so partial snapshot loads only mention the
// plugins that were intentionally requested for this registry.
discoverablePlugins: manifestRegistry.plugins
.filter((plugin) => !onlyPluginIdSet || onlyPluginIdSet.has(plugin.id))
.map((plugin) => ({
id: plugin.id,
source: plugin.source,
origin: plugin.origin,
})),
});
const provenance = buildProvenanceIndex({
config: cfg,
@@ -786,6 +823,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
const pluginId = manifestRecord.id;
// Filter again at import time as a final guard. The earlier manifest filter keeps
// warnings scoped; this one prevents loading/registering anything outside the scope.
if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) {
continue;
}
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) {
const record = createPluginRecord({
@@ -1059,7 +1101,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
if (typeof memorySlot === "string" && !memorySlotMatched) {
// Scoped snapshot loads may intentionally omit the configured memory plugin, so only
// emit the missing-memory diagnostic for full registry loads.
if (!onlyPluginIdSet && typeof memorySlot === "string" && !memorySlotMatched) {
registry.diagnostics.push({
level: "warn",
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
@@ -1076,7 +1120,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) {
setCachedPluginRegistry(cacheKey, registry);
}
activatePluginRegistry(registry, cacheKey);
if (shouldActivate) {
activatePluginRegistry(registry, cacheKey);
}
return registry;
}