fix(plugins): refresh gateway hooks before inbound dispatch

This commit is contained in:
Peter Steinberger
2026-04-25 02:01:01 +01:00
parent fde4bf7fc1
commit 9ca1f1a64e
5 changed files with 84 additions and 2 deletions

View File

@@ -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/<id>` 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/<id>` sources no longer pass access checks but fail file open. Thanks @steipete.

View File

@@ -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,
},
});
});
});

View File

@@ -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,
}

View File

@@ -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();

View File

@@ -79,6 +79,7 @@ let getReplyFromConfigRuntimePromise: Promise<
> | null = null;
let abortRuntimePromise: Promise<typeof import("./abort.runtime.js")> | null = null;
let ttsRuntimePromise: Promise<typeof import("../../tts/tts.runtime.js")> | null = null;
let runtimePluginsPromise: Promise<typeof import("../../agents/runtime-plugins.js")> | null = null;
let replyMediaPathsRuntimePromise: Promise<typeof import("./reply-media-paths.runtime.js")> | 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,