fix(cli): resolve message channel plugin scopes

This commit is contained in:
Peter Steinberger
2026-04-27 21:02:00 +01:00
parent 0c305596a2
commit 5e49e8590d
7 changed files with 98 additions and 10 deletions

View File

@@ -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.

View File

@@ -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`):

View File

@@ -34,6 +34,10 @@ const mocks = vi.hoisted(() => ({
getActivePluginRegistry: vi.fn<typeof import("../plugins/runtime.js").getActivePluginRegistry>(),
resolveConfiguredChannelPluginIds:
vi.fn<typeof import("../plugins/channel-plugin-ids.js").resolveConfiguredChannelPluginIds>(),
resolveDiscoverableScopedChannelPluginIds:
vi.fn<
typeof import("../plugins/channel-plugin-ids.js").resolveDiscoverableScopedChannelPluginIds
>(),
resolveChannelPluginIds:
vi.fn<typeof import("../plugins/channel-plugin-ids.js").resolveChannelPluginIds>(),
resolvePluginRuntimeLoadContext:
@@ -59,6 +63,9 @@ vi.mock("../plugins/channel-plugin-ids.js", () => ({
resolveConfiguredChannelPluginIds: (
...args: Parameters<typeof mocks.resolveConfiguredChannelPluginIds>
) => mocks.resolveConfiguredChannelPluginIds(...args),
resolveDiscoverableScopedChannelPluginIds: (
...args: Parameters<typeof mocks.resolveDiscoverableScopedChannelPluginIds>
) => mocks.resolveDiscoverableScopedChannelPluginIds(...args),
resolveChannelPluginIds: (...args: Parameters<typeof mocks.resolveChannelPluginIds>) =>
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<string, unknown>;
return {

View File

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

View File

@@ -34,14 +34,14 @@ async function runPluginStopHooks(): Promise<void> {
function resolveMessagePluginLoadOptions(
opts: Record<string, unknown>,
): { 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" };
}

View File

@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
getActivePluginRegistry: vi.fn<typeof import("../runtime.js").getActivePluginRegistry>(),
resolveConfiguredChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveConfiguredChannelPluginIds>(),
resolveDiscoverableScopedChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveDiscoverableScopedChannelPluginIds>(),
resolveChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveChannelPluginIds>(),
applyPluginAutoEnable:
@@ -39,6 +41,9 @@ vi.mock("../channel-plugin-ids.js", () => ({
resolveConfiguredChannelPluginIds: (
...args: Parameters<typeof mocks.resolveConfiguredChannelPluginIds>
) => mocks.resolveConfiguredChannelPluginIds(...args),
resolveDiscoverableScopedChannelPluginIds: (
...args: Parameters<typeof mocks.resolveDiscoverableScopedChannelPluginIds>
) => mocks.resolveDiscoverableScopedChannelPluginIds(...args),
resolveChannelPluginIds: (...args: Parameters<typeof mocks.resolveChannelPluginIds>) =>
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",

View File

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