From 1b557ffe658a9820fa03a544699b2bfd66adb6d9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 30 Mar 2026 21:53:26 +0530 Subject: [PATCH] fix(plugins): keep snapshot hook loads isolated --- src/plugins/loader.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/plugins/loader.ts | 2 +- src/plugins/registry.ts | 14 +++++++++----- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 6bf633ec471..c2e107f1933 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a7f975e7798..378279357d8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -938,7 +938,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi logger, runtime, coreGatewayHandlers: options.coreGatewayHandlers as Record, - suppressGlobalCommands: !shouldActivate, + activateGlobalSideEffects: shouldActivate, }); const discovery = discoverOpenClawPlugins({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index a50cdb8e043..958520816fc 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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({