From 5e49e8590dbb9d2d71f8d305fd52f0a64ddc83ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 21:02:00 +0100 Subject: [PATCH] fix(cli): resolve message channel plugin scopes --- CHANGELOG.md | 2 +- docs/cli/message.md | 2 +- src/cli/plugin-registry.test.ts | 9 +++ src/cli/program/message/helpers.test.ts | 4 +- src/cli/program/message/helpers.ts | 4 +- .../runtime/runtime-registry-loader.test.ts | 55 +++++++++++++++++++ .../runtime/runtime-registry-loader.ts | 32 +++++++++-- 7 files changed, 98 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc0e5dcb11..d553a6baeae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Docs: https://docs.openclaw.ai - Channels/sessions: skip last-route writes when inbound session recording explicitly disables creation, so plugin-owned guarded inbound paths cannot create route-only phantom sessions. Carries forward #73009. Thanks @jzakirov. - Channels/Telegram: skip the optional webhook-info API call during polling-mode status checks and startup bot-label probes so long-polling setups avoid an unnecessary Telegram round trip. Carries forward #72990. Thanks @danielgruneberg. -- CLI/message: load only the selected channel plugin for targeted `openclaw message` actions, and fall back to configured channel plugins when the channel must be inferred, so scripted sends avoid full bundled plugin registry scans. Fixes #73006. Thanks @jasonftl. +- CLI/message: resolve targeted `openclaw message` channels to their owning plugin before loading the registry, and fall back to configured channel plugins when the channel must be inferred, so scripted sends avoid full bundled plugin registry scans without assuming channel ids match plugin ids. Fixes #73006. Thanks @jasonftl. - CLI/models: keep route-first `models status --json` stdout reserved for the JSON payload by routing auth-profile and startup diagnostics to stderr. Fixes #72962. Thanks @vishutdhar. - Sessions: ignore future-dated session activity timestamps during reset freshness checks and cap future `updatedAt` values at the merge boundary so clock-skewed messages cannot keep stale sessions alive forever. Fixes #72989. Thanks @martingarramon. - Sessions: apply search, activity filters, and limits before gateway row enrichment so bounded session lists avoid scanning discarded transcripts. Carries forward #72978. Thanks @yeager. diff --git a/docs/cli/message.md b/docs/cli/message.md index 949d142b182..4cbd4a59263 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -22,7 +22,7 @@ Channel selection: - `--channel` required if more than one channel is configured. - If exactly one channel is configured, it becomes the default. - Values: `discord|googlechat|imessage|matrix|mattermost|msteams|signal|slack|telegram|whatsapp` (Mattermost requires plugin) -- `openclaw message` loads only the selected channel plugin when `--channel` or a channel-prefixed target is present; otherwise it loads configured channel plugins for default-channel inference. +- `openclaw message` resolves the selected channel to its owning plugin when `--channel` or a channel-prefixed target is present; otherwise it loads configured channel plugins for default-channel inference. Target formats (`--target`): diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts index f20bc73ebf6..76819b447eb 100644 --- a/src/cli/plugin-registry.test.ts +++ b/src/cli/plugin-registry.test.ts @@ -34,6 +34,10 @@ const mocks = vi.hoisted(() => ({ getActivePluginRegistry: vi.fn(), resolveConfiguredChannelPluginIds: vi.fn(), + resolveDiscoverableScopedChannelPluginIds: + vi.fn< + typeof import("../plugins/channel-plugin-ids.js").resolveDiscoverableScopedChannelPluginIds + >(), resolveChannelPluginIds: vi.fn(), resolvePluginRuntimeLoadContext: @@ -59,6 +63,9 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({ resolveConfiguredChannelPluginIds: ( ...args: Parameters ) => mocks.resolveConfiguredChannelPluginIds(...args), + resolveDiscoverableScopedChannelPluginIds: ( + ...args: Parameters + ) => mocks.resolveDiscoverableScopedChannelPluginIds(...args), resolveChannelPluginIds: (...args: Parameters) => mocks.resolveChannelPluginIds(...args), })); @@ -119,12 +126,14 @@ describe("ensurePluginRegistryLoaded", () => { mocks.resolveRuntimePluginRegistry.mockReset(); mocks.getActivePluginRegistry.mockReset(); mocks.resolveConfiguredChannelPluginIds.mockReset(); + mocks.resolveDiscoverableScopedChannelPluginIds.mockReset(); mocks.resolveChannelPluginIds.mockReset(); mocks.resolvePluginRuntimeLoadContext.mockReset(); resetPluginRegistryLoadedForTests(); mocks.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry()); mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined); + mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue([]); mocks.resolvePluginRuntimeLoadContext.mockImplementation((options) => { const rawConfig = (options?.config ?? {}) as Record; return { diff --git a/src/cli/program/message/helpers.test.ts b/src/cli/program/message/helpers.test.ts index bcf8a512d16..f11655aa509 100644 --- a/src/cli/program/message/helpers.test.ts +++ b/src/cli/program/message/helpers.test.ts @@ -98,7 +98,7 @@ describe("runMessageAction", () => { expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "configured-channels", - onlyPluginIds: ["discord"], + onlyChannelIds: ["discord"], }); expect(exitMock).toHaveBeenCalledOnce(); expect(exitMock).toHaveBeenCalledWith(0); @@ -117,7 +117,7 @@ describe("runMessageAction", () => { expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "configured-channels", - onlyPluginIds: ["telegram"], + onlyChannelIds: ["telegram"], }); }); diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index 582563723a3..013d7dc28e4 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -34,14 +34,14 @@ async function runPluginStopHooks(): Promise { function resolveMessagePluginLoadOptions( opts: Record, -): { scope: PluginRegistryScope; onlyPluginIds?: string[] } | undefined { +): { scope: PluginRegistryScope; onlyChannelIds?: string[] } | undefined { const scopedChannel = resolveMessageSecretScope({ channel: opts.channel, target: opts.target, targets: opts.targets, }).channel; if (scopedChannel) { - return { scope: "configured-channels", onlyPluginIds: [scopedChannel] }; + return { scope: "configured-channels", onlyChannelIds: [scopedChannel] }; } return { scope: "configured-channels" }; } diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index 6eb9815b916..dfeb7ad91ae 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({ getActivePluginRegistry: vi.fn(), resolveConfiguredChannelPluginIds: vi.fn(), + resolveDiscoverableScopedChannelPluginIds: + vi.fn(), resolveChannelPluginIds: vi.fn(), applyPluginAutoEnable: @@ -39,6 +41,9 @@ vi.mock("../channel-plugin-ids.js", () => ({ resolveConfiguredChannelPluginIds: ( ...args: Parameters ) => mocks.resolveConfiguredChannelPluginIds(...args), + resolveDiscoverableScopedChannelPluginIds: ( + ...args: Parameters + ) => mocks.resolveDiscoverableScopedChannelPluginIds(...args), resolveChannelPluginIds: (...args: Parameters) => mocks.resolveChannelPluginIds(...args), })); @@ -67,6 +72,7 @@ describe("ensurePluginRegistryLoaded", () => { mocks.resolveRuntimePluginRegistry.mockReset(); mocks.getActivePluginRegistry.mockReset(); mocks.resolveConfiguredChannelPluginIds.mockReset(); + mocks.resolveDiscoverableScopedChannelPluginIds.mockReset(); mocks.resolveChannelPluginIds.mockReset(); mocks.applyPluginAutoEnable.mockReset(); mocks.resolveAgentWorkspaceDir.mockClear(); @@ -95,6 +101,7 @@ describe("ensurePluginRegistryLoaded", () => { demo: ["demo configured"], }, })); + mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue([]); }); it("uses the shared runtime load context for configured-channel loads", () => { @@ -215,6 +222,54 @@ describe("ensurePluginRegistryLoaded", () => { ); }); + it("maps explicit channel scopes to owner plugin ids before loading", () => { + const rawConfig = { channels: { "external-chat": { token: "configured" } } }; + mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue(["external-chat-plugin"]); + + ensurePluginRegistryLoaded({ + scope: "configured-channels", + config: rawConfig as never, + onlyChannelIds: ["external-chat"], + }); + + expect(mocks.resolveDiscoverableScopedChannelPluginIds).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + ...rawConfig, + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + demo: { enabled: true }, + }), + }), + }), + activationSourceConfig: rawConfig, + channelIds: ["external-chat"], + workspaceDir: "/resolved-workspace", + }), + ); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["external-chat-plugin"], + entries: expect.objectContaining({ + "external-chat-plugin": { enabled: true }, + }), + }), + }), + activationSourceConfig: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: ["external-chat-plugin"], + entries: expect.objectContaining({ + "external-chat-plugin": { enabled: true }, + }), + }), + }), + onlyPluginIds: ["external-chat-plugin"], + }), + ); + }); + it("forwards explicit empty scopes without widening to channel resolution", () => { ensurePluginRegistryLoaded({ scope: "configured-channels", diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 61c1563e49a..b0799e7bf92 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -3,6 +3,7 @@ import { withActivatedPluginIds } from "../activation-context.js"; import { resolveChannelPluginIds, resolveConfiguredChannelPluginIds, + resolveDiscoverableScopedChannelPluginIds, } from "../channel-plugin-ids.js"; import { loadOpenClawPlugins, resolveRuntimePluginRegistry } from "../loader.js"; import { @@ -90,11 +91,30 @@ export function ensurePluginRegistryLoaded(options?: { env?: NodeJS.ProcessEnv; workspaceDir?: string; onlyPluginIds?: string[]; + onlyChannelIds?: string[]; }): void { const scope = options?.scope ?? "all"; - const requestedPluginIds = normalizePluginIdScope(options?.onlyPluginIds); - const scopedLoad = hasExplicitPluginIdScope(requestedPluginIds); + const requestedPluginIdsFromOptions = normalizePluginIdScope(options?.onlyPluginIds); + const requestedChannelIds = normalizePluginIdScope(options?.onlyChannelIds); const context = resolvePluginRuntimeLoadContext(options); + const requestedChannelOwnerPluginIds = + requestedChannelIds === undefined + ? undefined + : resolveDiscoverableScopedChannelPluginIds({ + config: context.config, + activationSourceConfig: context.activationSourceConfig, + channelIds: requestedChannelIds, + workspaceDir: context.workspaceDir, + env: context.env, + }); + const requestedPluginIds = + requestedChannelOwnerPluginIds === undefined + ? requestedPluginIdsFromOptions + : normalizePluginIdScope([ + ...(requestedPluginIdsFromOptions ?? []), + ...requestedChannelOwnerPluginIds, + ]); + const scopedLoad = hasExplicitPluginIdScope(requestedPluginIds); const expectedChannelPluginIds = scopedLoad ? (requestedPluginIds ?? []) : scope === "configured-channels" @@ -129,14 +149,18 @@ export function ensurePluginRegistryLoaded(options?: { return; } const scopedConfig = - !scopedLoad && scope === "configured-channels" && expectedChannelPluginIds.length > 0 + scope === "configured-channels" && + expectedChannelPluginIds.length > 0 && + (!scopedLoad || requestedChannelOwnerPluginIds !== undefined) ? (withActivatedPluginIds({ config: context.config, pluginIds: expectedChannelPluginIds, }) ?? context.config) : context.config; const scopedActivationSourceConfig = - !scopedLoad && scope === "configured-channels" && expectedChannelPluginIds.length > 0 + scope === "configured-channels" && + expectedChannelPluginIds.length > 0 && + (!scopedLoad || requestedChannelOwnerPluginIds !== undefined) ? (withActivatedPluginIds({ config: context.activationSourceConfig, pluginIds: expectedChannelPluginIds,