fix(cli): avoid local preload for gateway-owned message actions (#76898)

* fix(cli): avoid Telegram send preload

* fix(cli): include telegram target prefixes

* fix(cli): generalize gateway message preload skip

* refactor(cli): clarify message preload planning

* fix(cli): keep broadcast plugin preload
This commit is contained in:
Peter Steinberger
2026-05-03 23:44:19 +01:00
committed by GitHub
parent 9f2f75ff02
commit 6529cad499
3 changed files with 210 additions and 9 deletions

View File

@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001.
- Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc.
- CLI/plugins: reject unowned command roots such as `openclaw foo` before managed proxy startup and full plugin CLI runtime loading while preserving manifest-owned and CLI-metadata-owned plugin commands. Fixes #75287. Thanks @neilofneils404.
- CLI/message: skip local configured-channel plugin preload for explicit gateway-owned message actions, letting normalized CLI delivery delegate to the gateway without initializing channel runtime in the short-lived CLI process. Fixes #75477.
- Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc.
- Plugins/tools: cold-load selected plugin tool registries when the active registry only has partial tool coverage, so wildcard-expanded allowlists no longer hide installed plugin tools from `tools.effective`. Fixes #76780. Thanks @lilesjtu.
- Plugins/tools: compare cached and runtime plugin tool name conflicts with normalized core tool names, so case variants of core tools are blocked instead of leaking duplicate tool registrations. Thanks @vincentkoc.

View File

@@ -5,6 +5,11 @@ vi.mock("../../../commands/message.js", () => ({
messageCommand: messageCommandMock,
}));
const getChannelPluginMock = vi.fn();
vi.mock("../../../channels/plugins/index.js", () => ({
getChannelPlugin: getChannelPluginMock,
}));
vi.mock("../../../globals.js", () => ({
danger: (s: string) => s,
setVerbose: vi.fn(),
@@ -70,6 +75,14 @@ async function runSendAction(opts: Record<string, unknown> = {}) {
await expect(runMessageAction("send", { ...baseSendOptions, ...opts })).rejects.toThrow("exit");
}
function mockChannelExecutionModes(modes: Record<string, "gateway" | "local"> = {}) {
getChannelPluginMock.mockImplementation((id: string) => ({
actions: {
resolveExecutionMode: () => modes[id] ?? "local",
},
}));
}
function expectNoAccountFieldInPassedOptions() {
const passedOpts = (
messageCommandMock.mock.calls as unknown as Array<[Record<string, unknown>]>
@@ -84,6 +97,8 @@ function expectNoAccountFieldInPassedOptions() {
describe("runMessageAction", () => {
beforeEach(() => {
vi.clearAllMocks();
getChannelPluginMock.mockReset();
mockChannelExecutionModes({ telegram: "gateway" });
messageCommandMock.mockClear().mockResolvedValue(undefined);
hasHooksMock.mockClear().mockReturnValue(false);
runGatewayStopMock.mockClear().mockResolvedValue(undefined);
@@ -113,12 +128,152 @@ describe("runMessageAction", () => {
});
it("narrows plugin loading from a channel-prefixed target", async () => {
await runSendAction({ channel: undefined, target: "telegram:12345" });
await runSendAction({ channel: undefined, target: "discord:channel:12345" });
expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({
scope: "configured-channels",
onlyChannelIds: ["discord"],
});
});
it("skips local plugin preload for any gateway-owned scoped channel action", async () => {
mockChannelExecutionModes({ discord: "gateway" });
await runSendAction({ target: "channel:12345" });
expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(messageCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "send",
channel: "discord",
target: "channel:12345",
message: "hi",
}),
expect.anything(),
expect.anything(),
);
expect(exitMock).toHaveBeenCalledWith(0);
});
it("keeps broadcast on the local preload path for same-channel prefixed targets", async () => {
const runMessageAction = createRunMessageAction();
await expect(
runMessageAction("broadcast", {
targets: ["telegram:1", "telegram:2"],
message: "hi",
}),
).rejects.toThrow("exit");
expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({
scope: "configured-channels",
onlyChannelIds: ["telegram"],
});
expect(messageCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "broadcast",
targets: ["telegram:1", "telegram:2"],
message: "hi",
}),
expect.anything(),
expect.anything(),
);
});
it("keeps unknown actions on the local preload path", async () => {
mockChannelExecutionModes({ discord: "gateway" });
const runMessageAction = createRunMessageAction();
await expect(
runMessageAction("custom-action", {
...baseSendOptions,
target: "channel:12345",
}),
).rejects.toThrow("exit");
expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({
scope: "configured-channels",
onlyChannelIds: ["discord"],
});
expect(messageCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "custom-action",
}),
expect.anything(),
expect.anything(),
);
});
it("preloads when the scoped channel plugin is not cheaply available", async () => {
getChannelPluginMock.mockReturnValue(undefined);
await runSendAction({ target: "channel:12345" });
expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({
scope: "configured-channels",
onlyChannelIds: ["discord"],
});
});
it("keeps target-prefixed Telegram sends from local plugin preload", async () => {
await runSendAction({ channel: undefined, target: "telegram:12345" });
expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(messageCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "send",
target: "telegram:12345",
message: "hi",
}),
expect.anything(),
expect.anything(),
);
expect(exitMock).toHaveBeenCalledWith(0);
});
it("keeps explicit Telegram sends on the normal command path without local plugin preload", async () => {
await runSendAction({
channel: "telegram",
account: "default",
target: "@ops",
media: "./diagram.png",
presentation: '{"blocks":[{"type":"buttons","buttons":[{"label":"OK","value":"ok"}]}]}',
delivery: '{"pin":true}',
forceDocument: true,
});
expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(messageCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "send",
channel: "telegram",
accountId: "default",
target: "@ops",
message: "hi",
media: "./diagram.png",
presentation: '{"blocks":[{"type":"buttons","buttons":[{"label":"OK","value":"ok"}]}]}',
delivery: '{"pin":true}',
forceDocument: true,
}),
expect.anything(),
expect.anything(),
);
expectNoAccountFieldInPassedOptions();
expect(exitMock).toHaveBeenCalledWith(0);
});
it("keeps Telegram dry-runs on the local preload path for local validation", async () => {
await runSendAction({
channel: "telegram",
target: "@ops",
dryRun: true,
});
expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({
scope: "configured-channels",
onlyChannelIds: ["telegram"],
});
expect(messageCommandMock).toHaveBeenCalled();
});
it("loads configured channel plugins for mixed broadcast target prefixes", async () => {

View File

@@ -1,4 +1,9 @@
import type { Command } from "commander";
import { getChannelPlugin } from "../../../channels/plugins/index.js";
import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
} from "../../../channels/plugins/types.public.js";
import { resolveMessageSecretScope } from "../../../cli/message-secret-scope.js";
import { messageCommand } from "../../../commands/message.js";
import { danger, setVerbose } from "../../../globals.js";
@@ -18,6 +23,13 @@ export type MessageCliHelpers = {
const GATEWAY_STOP_TIMEOUT_MS = 2500;
const ACTIONS_WITHOUT_STOP_HOOKS = new Set(["read"]);
const ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD = new Set(["broadcast"]);
const CHANNEL_MESSAGE_ACTION_NAME_SET = new Set<string>(CHANNEL_MESSAGE_ACTION_NAMES);
type MessagePluginLoadOptions = { scope: PluginRegistryScope; onlyChannelIds?: string[] };
type MessagePluginPreloadPlan =
| { preload: true; loadOptions: MessagePluginLoadOptions }
| { preload: false };
function normalizeMessageOptions(opts: Record<string, unknown>): Record<string, unknown> {
const { account, ...rest } = opts;
@@ -49,18 +61,48 @@ async function runPluginStopHooks(): Promise<void> {
}
}
function resolveMessagePluginLoadOptions(
opts: Record<string, unknown>,
): { scope: PluginRegistryScope; onlyChannelIds?: string[] } | undefined {
const scopedChannel = resolveMessageSecretScope({
function resolveScopedMessageChannel(opts: Record<string, unknown>): string | undefined {
return resolveMessageSecretScope({
channel: opts.channel,
target: opts.target,
targets: opts.targets,
}).channel;
if (scopedChannel) {
return { scope: "configured-channels", onlyChannelIds: [scopedChannel] };
}
function asChannelMessageActionName(action: string): ChannelMessageActionName | undefined {
return CHANNEL_MESSAGE_ACTION_NAME_SET.has(action)
? (action as ChannelMessageActionName)
: undefined;
}
function isGatewayOwnedMessageAction(action: string, scopedChannel: string | undefined): boolean {
const messageAction = asChannelMessageActionName(action);
if (!messageAction || !scopedChannel) {
return false;
}
return { scope: "configured-channels" };
const plugin = getChannelPlugin(scopedChannel);
const executionMode = plugin?.actions?.resolveExecutionMode?.({
action: messageAction,
});
return executionMode === "gateway";
}
function resolveMessagePluginPreloadPlan(
action: string,
opts: Record<string, unknown>,
): MessagePluginPreloadPlan {
const scopedChannel = resolveScopedMessageChannel(opts);
const loadOptions = scopedChannel
? { scope: "configured-channels" as const, onlyChannelIds: [scopedChannel] }
: { scope: "configured-channels" as const };
if (
opts.dryRun === true ||
ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD.has(action) ||
!isGatewayOwnedMessageAction(action, scopedChannel)
) {
return { preload: true, loadOptions };
}
return { preload: false };
}
export function createMessageCliHelpers(
@@ -86,7 +128,10 @@ export function createMessageCliHelpers(
await runCommandWithRuntime(
defaultRuntime,
async () => {
ensurePluginRegistryLoaded(resolveMessagePluginLoadOptions(opts));
const preloadPlan = resolveMessagePluginPreloadPlan(action, opts);
if (preloadPlan.preload) {
ensurePluginRegistryLoaded(preloadPlan.loadOptions);
}
const deps = createDefaultDeps();
await messageCommand(
{