From 9ca1f1a64e7ace44875479fc98e25ed9a3ed116e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 02:01:01 +0100 Subject: [PATCH] fix(plugins): refresh gateway hooks before inbound dispatch --- CHANGELOG.md | 1 + src/agents/runtime-plugins.test.ts | 39 +++++++++++++++++++ src/agents/runtime-plugins.ts | 6 ++- .../reply/dispatch-from-config.test.ts | 29 ++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 11 +++++- 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4182225a9b3..fc17bc00a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai - 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. - Plugins/hooks: keep the gateway-bindable hook runner installed when later default-mode plugin loads activate a different registry, preserving Gateway subagent lifecycle hooks across runtime cache misses. Fixes #63166. Thanks @steipete. +- Plugins/hooks: refresh live Gateway runtime hooks before inbound channel dispatch, so externally installed plugins keep `message_received`, `before_dispatch`, and reply hooks active after scoped startup plugin loads. Fixes #71167. Thanks @steipete. - Media/input: resolve canonical inbound media refs through the shared media loader so native prompt image replay and explicit image/PDF tools can read `media://inbound/` and managed inbound replay paths under workspace-only file policy. Thanks @steipete. - Media/tools: centralize media reference scheme classification for image, PDF, image-generation, video-generation, and music-generation inputs so managed inbound refs are accepted consistently. Thanks @steipete. - Control UI/media: resolve canonical inbound media refs before serving assistant media previews, so `media://inbound/` sources no longer pass access checks but fail file open. Thanks @steipete. diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 25548294f10..4b0d1efddd4 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -2,18 +2,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ resolveRuntimePluginRegistry: vi.fn(), + getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">( + () => "default", + ), })); vi.mock("../plugins/loader.js", () => ({ resolveRuntimePluginRegistry: hoisted.resolveRuntimePluginRegistry, })); +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode, +})); + describe("ensureRuntimePluginsLoaded", () => { let ensureRuntimePluginsLoaded: typeof import("./runtime-plugins.js").ensureRuntimePluginsLoaded; beforeEach(async () => { hoisted.resolveRuntimePluginRegistry.mockReset(); hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined); + hoisted.getActivePluginRuntimeSubagentMode.mockReset(); + hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default"); vi.resetModules(); ({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js")); }); @@ -45,4 +54,34 @@ describe("ensureRuntimePluginsLoaded", () => { }, }); }); + + it("does not enable gateway subagent binding for normal runtime loads", async () => { + ensureRuntimePluginsLoaded({ + config: {} as never, + workspaceDir: "/tmp/workspace", + }); + + expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ + config: {} as never, + workspaceDir: "/tmp/workspace", + runtimeOptions: undefined, + }); + }); + + it("inherits gateway-bindable mode from an active gateway registry", async () => { + hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); + + ensureRuntimePluginsLoaded({ + config: {} as never, + workspaceDir: "/tmp/workspace", + }); + + expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ + config: {} as never, + workspaceDir: "/tmp/workspace", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }); + }); }); diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 751046c9190..6838c258a5d 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveRuntimePluginRegistry } from "../plugins/loader.js"; +import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js"; import { resolveUserPath } from "../utils.js"; export function ensureRuntimePluginsLoaded(params: { @@ -11,10 +12,13 @@ export function ensureRuntimePluginsLoaded(params: { typeof params.workspaceDir === "string" && params.workspaceDir.trim() ? resolveUserPath(params.workspaceDir) : undefined; + const allowGatewaySubagentBinding = + params.allowGatewaySubagentBinding === true || + getActivePluginRuntimeSubagentMode() === "gateway-bindable"; const loadOptions = { config: params.config, workspaceDir, - runtimeOptions: params.allowGatewaySubagentBinding + runtimeOptions: allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true, } diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index e6e1e1b2790..adbf5c57959 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -147,6 +147,9 @@ const replyMediaPathMocks = vi.hoisted(() => ({ (_params?: unknown) => async (payload: ReplyPayload) => payload, ), })); +const runtimePluginMocks = vi.hoisted(() => ({ + ensureRuntimePluginsLoaded: vi.fn(), +})); const threadInfoMocks = vi.hoisted(() => ({ parseSessionThreadInfo: vi.fn< (sessionKey: string | undefined) => { @@ -337,6 +340,9 @@ vi.mock("./reply-media-paths.runtime.js", () => ({ createReplyMediaPathNormalizer: (params: unknown) => replyMediaPathMocks.createReplyMediaPathNormalizer(params), })); +vi.mock("../../agents/runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, +})); vi.mock("../../tts/status-config.js", () => ({ resolveStatusTtsSnapshot: () => ({ autoMode: "always", @@ -657,7 +663,30 @@ describe("dispatchReplyFromConfig", () => { replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue( async (payload: ReplyPayload) => payload, ); + runtimePluginMocks.ensureRuntimePluginsLoaded.mockClear(); }); + + it("loads runtime plugins before reading inbound hook state", async () => { + setNoAbort(); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "whatsapp", + SessionKey: "agent:main:main", + }); + + const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: cfg, + workspaceDir: expect.any(String), + }); + expect(runtimePluginMocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan( + hookMocks.runner.hasHooks.mock.invocationCallOrder[0], + ); + }); + it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { setNoAbort(); mocks.routeReply.mockClear(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index df13e655e47..cb49054cae3 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -79,6 +79,7 @@ let getReplyFromConfigRuntimePromise: Promise< > | null = null; let abortRuntimePromise: Promise | null = null; let ttsRuntimePromise: Promise | null = null; +let runtimePluginsPromise: Promise | null = null; let replyMediaPathsRuntimePromise: Promise | null = null; @@ -102,6 +103,11 @@ function loadTtsRuntime() { return ttsRuntimePromise; } +function loadRuntimePlugins() { + runtimePluginsPromise ??= import("../../agents/runtime-plugins.js"); + return runtimePluginsPromise; +} + function loadReplyMediaPathsRuntime() { replyMediaPathsRuntimePromise ??= import("./reply-media-paths.runtime.js"); return replyMediaPathsRuntimePromise; @@ -318,6 +324,9 @@ export async function dispatchReplyFromConfig( ctx.MessageThreadId ?? parseSessionThreadInfoFast(acpDispatchSessionKey).threadId; const inboundAudio = isInboundAudioContext(ctx); const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto); + const workspaceDir = resolveAgentWorkspaceDir(cfg, sessionAgentId); + const { ensureRuntimePluginsLoaded } = await loadRuntimePlugins(); + ensureRuntimePluginsLoaded({ config: cfg, workspaceDir }); const hookRunner = getGlobalHookRunner(); // Extract message context for hooks (plugin and internal) @@ -378,7 +387,7 @@ export async function dispatchReplyFromConfig( const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ cfg, sessionKey: acpDispatchSessionKey, - workspaceDir: resolveAgentWorkspaceDir(cfg, sessionAgentId), + workspaceDir, messageProvider: deliveryChannel, accountId: replyRoute.accountId, groupId,