From 0270428645f0916b4f354c68507a0368c1dd8def Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 00:14:02 +0100 Subject: [PATCH] fix(plugins): reuse gateway boot registry for runtime ensures Co-authored-by: Mark Ramos <6416874+markthebest12@users.noreply.github.com> --- CHANGELOG.md | 1 + src/plugins/loader.test.ts | 45 +++++++++++++++++++ .../runtime/runtime-registry-loader.test.ts | 30 +++++++++++++ .../runtime/runtime-registry-loader.ts | 43 +++++++++++------- 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 649b54f38ab..c68aac80e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Slack/Assistant: accept Slack Assistant DM `message_changed` events when their metadata identifies the human sender, while continuing to drop self-authored bot edits. Fixes #55445. Thanks @AlfredPros. - Agents/failover: stop body-less HTTP 400/422 proxy failures from defaulting to `"format"` classification, so embedded retries surface the opaque provider failure instead of falling into a compaction loop. Fixes #66462. (#67024) Thanks @altaywtf and @HongzhuLiu. - Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih. +- Plugins/loader: reuse the compatible active Gateway registry for broad runtime plugin ensure calls after a gateway-bindable boot load, so non-bundled plugins no longer re-run `register()` during the same boot path. Fixes #69250. Thanks @markthebest12. ## 2026.4.24 diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d29929a41d7..327966e0ec9 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -77,6 +77,10 @@ import { listImportedRuntimePluginIds, setActivePluginRegistry, } from "./runtime.js"; +import { + __testing as runtimeRegistryLoaderTesting, + ensurePluginRegistryLoaded, +} from "./runtime/runtime-registry-loader.js"; import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; let cachedBundledTelegramDir = ""; let cachedBundledMemoryDir = ""; @@ -832,6 +836,7 @@ function expectEscapingEntryRejected(params: { afterEach(() => { clearRuntimeConfigSnapshot(); + runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests(); resetPluginLoaderTestStateForTest(); }); @@ -3518,6 +3523,46 @@ module.exports = { id: "throws-after-import", register() {} };`, delete (globalThis as Record)[marker]; }); + it("does not re-register non-bundled plugins after gateway-bindable boot loads", () => { + useNoBundledPlugins(); + const marker = "__openclawGatewayBootRegisterCount"; + const plugin = writePlugin({ + id: "costclaw-boot-cache", + filename: "costclaw-boot-cache.cjs", + body: `module.exports = { + id: "costclaw-boot-cache", + register() { + globalThis.${marker} = (globalThis.${marker} || 0) + 1; + }, + };`, + }); + const config = { + plugins: { + load: { paths: [plugin.file] }, + allow: ["costclaw-boot-cache"], + entries: { + "costclaw-boot-cache": { enabled: true }, + }, + }, + }; + + loadOpenClawPlugins({ + workspaceDir: plugin.dir, + config, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }); + ensurePluginRegistryLoaded({ + scope: "all", + workspaceDir: plugin.dir, + config, + }); + + expect((globalThis as Record)[marker]).toBe(1); + delete (globalThis as Record)[marker]; + }); + it("re-initializes global hook runner when serving registry from cache", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index 2ed895910b7..6eb9815b916 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -3,6 +3,7 @@ import { createEmptyPluginRegistry } from "../registry.js"; const mocks = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), + resolveRuntimePluginRegistry: vi.fn(), getActivePluginRegistry: vi.fn(), resolveConfiguredChannelPluginIds: vi.fn(), @@ -24,6 +25,8 @@ let resetPluginRegistryLoadedForTests: typeof import("./runtime-registry-loader. vi.mock("../loader.js", () => ({ loadOpenClawPlugins: (...args: Parameters) => mocks.loadOpenClawPlugins(...args), + resolveRuntimePluginRegistry: (...args: Parameters) => + mocks.resolveRuntimePluginRegistry(...args), })); vi.mock("../runtime.js", () => ({ @@ -61,6 +64,7 @@ describe("ensurePluginRegistryLoaded", () => { beforeEach(() => { mocks.loadOpenClawPlugins.mockReset(); + mocks.resolveRuntimePluginRegistry.mockReset(); mocks.getActivePluginRegistry.mockReset(); mocks.resolveConfiguredChannelPluginIds.mockReset(); mocks.resolveChannelPluginIds.mockReset(); @@ -70,6 +74,10 @@ describe("ensurePluginRegistryLoaded", () => { resetPluginRegistryLoadedForTests(); mocks.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry()); + mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry()); + mocks.resolveRuntimePluginRegistry.mockImplementation( + (...args: Parameters) => mocks.loadOpenClawPlugins(...args), + ); mocks.applyPluginAutoEnable.mockImplementation((params) => ({ config: params.config && typeof params.config === "object" @@ -255,4 +263,26 @@ describe("ensurePluginRegistryLoaded", () => { (mocks.loadOpenClawPlugins.mock.calls[0]?.[0] as { onlyPluginIds?: string[] }).onlyPluginIds, ).toBeUndefined(); }); + + it("reuses a compatible active registry instead of forcing a broad reload", () => { + const activeRegistry = createEmptyPluginRegistry(); + mocks.resolveRuntimePluginRegistry.mockReturnValue(activeRegistry); + + ensurePluginRegistryLoaded({ + scope: "all", + config: { plugins: { allow: ["demo"] } } as never, + }); + + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + throwOnLoadError: true, + }), + ); + expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith( + expect.not.objectContaining({ + onlyPluginIds: expect.any(Array), + }), + ); + expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 8be63200466..61c1563e49a 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -4,7 +4,7 @@ import { resolveChannelPluginIds, resolveConfiguredChannelPluginIds, } from "../channel-plugin-ids.js"; -import { loadOpenClawPlugins } from "../loader.js"; +import { loadOpenClawPlugins, resolveRuntimePluginRegistry } from "../loader.js"; import { hasExplicitPluginIdScope, hasNonEmptyPluginIdScope, @@ -73,6 +73,16 @@ function shouldForwardChannelScope(params: { return !params.scopedLoad && params.scope === "configured-channels"; } +function resolveOrLoadRuntimePluginRegistry( + loadOptions: Parameters[0], +): void { + // Prefer the runtime resolver so broad ensures can reuse compatible active + // registries, including gateway-bindable startup registries. + if (!resolveRuntimePluginRegistry(loadOptions)) { + loadOpenClawPlugins(loadOptions); + } +} + export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope; config?: OpenClawConfig; @@ -132,23 +142,22 @@ export function ensurePluginRegistryLoaded(options?: { pluginIds: expectedChannelPluginIds, }) ?? context.activationSourceConfig) : context.activationSourceConfig; - loadOpenClawPlugins( - buildPluginRuntimeLoadOptionsFromValues( - { - ...context, - config: scopedConfig, - activationSourceConfig: scopedActivationSourceConfig, - }, - { - throwOnLoadError: true, - ...(hasExplicitPluginIdScope(requestedPluginIds) || - shouldForwardChannelScope({ scope, scopedLoad }) || - hasNonEmptyPluginIdScope(expectedChannelPluginIds) - ? { onlyPluginIds: expectedChannelPluginIds } - : {}), - }, - ), + const loadOptions = buildPluginRuntimeLoadOptionsFromValues( + { + ...context, + config: scopedConfig, + activationSourceConfig: scopedActivationSourceConfig, + }, + { + throwOnLoadError: true, + ...(hasExplicitPluginIdScope(requestedPluginIds) || + shouldForwardChannelScope({ scope, scopedLoad }) || + hasNonEmptyPluginIdScope(expectedChannelPluginIds) + ? { onlyPluginIds: expectedChannelPluginIds } + : {}), + }, ); + resolveOrLoadRuntimePluginRegistry(loadOptions); if (!scopedLoad) { pluginRegistryLoaded = scope; }