fix(plugins): keep snapshot hook loads isolated

This commit is contained in:
Ayaan Zaidi 2026-03-30 21:53:26 +05:30
parent f849b8de97
commit 1b557ffe65
3 changed files with 47 additions and 6 deletions

View File

@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { clearInternalHooks, getRegisteredEventKeys } from "../hooks/internal-hooks.js";
import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
import { withEnv } from "../test-utils/env.js";
import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-state.js";
@ -1265,6 +1266,42 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
clearPluginCommands();
});
it("does not register internal hooks globally during non-activating loads", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "internal-hook-snapshot",
filename: "internal-hook-snapshot.cjs",
body: `module.exports = {
id: "internal-hook-snapshot",
register(api) {
api.registerHook("gateway:startup", () => {}, { name: "snapshot-hook" });
},
};`,
});
clearInternalHooks();
const scoped = loadOpenClawPlugins({
cache: false,
activate: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["internal-hook-snapshot"],
},
},
onlyPluginIds: ["internal-hook-snapshot"],
});
expect(scoped.plugins.find((entry) => entry.id === "internal-hook-snapshot")?.status).toBe(
"loaded",
);
expect(scoped.hooks.map((entry) => entry.entry.hook.name)).toEqual(["snapshot-hook"]);
expect(getRegisteredEventKeys()).toEqual([]);
clearInternalHooks();
});
it("can scope bundled provider loads to deepseek without hanging", () => {
if (prevBundledDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;

View File

@ -938,7 +938,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
logger,
runtime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
suppressGlobalCommands: !shouldActivate,
activateGlobalSideEffects: shouldActivate,
});
const discovery = discoverOpenClawPlugins({

View File

@ -243,9 +243,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;
// When false, keep registration local to the returned registry and avoid mutating
// process-global command/hook state during non-activating snapshot loads.
activateGlobalSideEffects?: boolean;
};
type PluginTypedHookPolicy = {
@ -376,7 +376,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
const hookSystemEnabled = config?.hooks?.internal?.enabled !== false;
if (!hookSystemEnabled || opts?.register === false) {
if (
!registryParams.activateGlobalSideEffects ||
!hookSystemEnabled ||
opts?.register === false
) {
return;
}
@ -812,7 +816,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
// 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) {
if (!registryParams.activateGlobalSideEffects) {
const validationError = validatePluginCommandDefinition(command);
if (validationError) {
pushDiagnostic({