From 265b97bbbaa2ada71f9e72b636c12bc3a19021e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 22:38:22 +0100 Subject: [PATCH] fix(cli): avoid plugin preload for agent bindings --- CHANGELOG.md | 3 ++ src/cli/command-catalog.ts | 15 +++++++ src/cli/command-path-policy.test.ts | 16 ++++++++ src/cli/command-startup-policy.test.ts | 18 ++++++++ src/commands/agents.bind.commands.test.ts | 50 +++++++++++++++++++---- src/commands/agents.bindings.ts | 48 ++++++++++++++++++++-- 6 files changed, 138 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7232047eb81..006b744dae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/agents: keep `agents bind`, `agents unbind`, and `agents bindings` on + setup-safe channel metadata paths so they do not preload bundled plugin + runtimes or stage runtime dependencies. Fixes #71743. - Plugins/registry: preserve explicit disabled plugin records during registry migration without persisting every unused bundled plugin discovered on disk. Thanks @shakkernerd. - Windows/native: keep CLI startup and bundled provider plugin loading off Windows ESM raw-path failure paths, fixing native onboarding/install smoke on diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index ede0e796e7b..a04b7a01b90 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -41,6 +41,21 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ { commandPath: ["channels"], policy: { loadPlugins: "always" } }, { commandPath: ["directory"], policy: { loadPlugins: "always" } }, { commandPath: ["agents"], policy: { loadPlugins: "always" } }, + { + commandPath: ["agents", "bind"], + exact: true, + policy: { loadPlugins: "never" }, + }, + { + commandPath: ["agents", "bindings"], + exact: true, + policy: { loadPlugins: "never" }, + }, + { + commandPath: ["agents", "unbind"], + exact: true, + policy: { loadPlugins: "never" }, + }, { commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } }, { commandPath: ["status"], diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 484c14a4533..ab02cff17dc 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -43,6 +43,22 @@ describe("command-path-policy", () => { }); }); + it("keeps agent binding commands on config-only startup", () => { + for (const commandPath of [ + ["agents", "bind"], + ["agents", "bindings"], + ["agents", "unbind"], + ]) { + expect(resolveCliCommandPathPolicy(commandPath)).toEqual({ + bypassConfigGuard: false, + routeConfigGuard: "never", + loadPlugins: "never", + hideBanner: false, + ensureCliPath: true, + }); + } + }); + it("resolves mixed startup-only rules", () => { expect(resolveCliCommandPathPolicy(["configure"])).toEqual({ bypassConfigGuard: true, diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 6bf36f6f5a3..75c1cb293e8 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -89,6 +89,24 @@ describe("command-startup-policy", () => { jsonOutputMode: true, }), ).toBe(false); + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["agents", "bind"], + jsonOutputMode: false, + }), + ).toBe(false); + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["agents", "bindings"], + jsonOutputMode: true, + }), + ).toBe(false); + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["agents", "unbind"], + jsonOutputMode: false, + }), + ).toBe(false); }); it("matches banner suppression policy", () => { diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts index 7e5789f602f..851da905062 100644 --- a/src/commands/agents.bind.commands.test.ts +++ b/src/commands/agents.bind.commands.test.ts @@ -9,6 +9,11 @@ import { } from "./agents.bind.test-support.js"; import { baseConfigSnapshot } from "./test-runtime-config-helpers.js"; +const pluginRegistryMocks = vi.hoisted(() => ({ + loadPluginRegistrySnapshot: vi.fn(() => ({})), + listPluginContributionIds: vi.fn(() => ["external-chat"]), +})); + vi.mock("../agents/agent-scope.js", () => ({ listAgentEntries: ( cfg: { @@ -28,6 +33,11 @@ vi.mock("../config/bindings.js", () => ({ (cfg.bindings ?? []).filter((binding) => Boolean(binding.match)), })); +vi.mock("../plugins/plugin-registry.js", () => ({ + loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot, + listPluginContributionIds: pluginRegistryMocks.listPluginContributionIds, +})); + type BindingResolverTestPlugin = Pick & { setup?: Pick, "resolveBindingAccountId">; }; @@ -59,6 +69,12 @@ function createBindingResolverTestPlugin(params: { } vi.mock("../channels/plugins/index.js", () => { + return { + getLoadedChannelPlugin: () => undefined, + }; +}); + +vi.mock("../channels/plugins/bundled.js", () => { const knownChannels = new Map([ [ "discord", @@ -78,17 +94,10 @@ vi.mock("../channels/plugins/index.js", () => { ], ]); return { - getChannelPlugin: (channel: string) => { + getBundledChannelSetupPlugin: (channel: string) => { const normalized = channel.trim().toLowerCase(); return knownChannels.get(normalized); }, - normalizeChannelId: (channel: string) => { - const normalized = channel.trim().toLowerCase(); - if (knownChannels.has(normalized)) { - return normalized; - } - return undefined; - }, }; }); @@ -104,6 +113,8 @@ describe("agents bind/unbind commands", () => { beforeEach(() => { resetAgentsBindTestHarness(); + pluginRegistryMocks.loadPluginRegistrySnapshot.mockClear(); + pluginRegistryMocks.listPluginContributionIds.mockClear(); }); it("lists all bindings by default", async () => { @@ -141,6 +152,29 @@ describe("agents bind/unbind commands", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("binds manifest-known external channels without loading plugin runtime", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await agentsBindCommand({ bind: ["external-chat:work"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [ + { + type: "route", + agentId: "main", + match: { channel: "external-chat", accountId: "work" }, + }, + ], + }), + ); + expect(pluginRegistryMocks.loadPluginRegistrySnapshot).toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + it("unbinds all routes for an agent", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts index 20acd54f5f4..3d67e23fa52 100644 --- a/src/commands/agents.bindings.ts +++ b/src/commands/agents.bindings.ts @@ -1,9 +1,15 @@ +import { getBundledChannelSetupPlugin } from "../channels/plugins/bundled.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; +import { normalizeChannelId as normalizeBundledChannelId } from "../channels/registry.js"; import { isRouteBinding, listRouteBindings } from "../config/bindings.js"; import type { AgentRouteBinding } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + listPluginContributionIds, + loadPluginRegistrySnapshot, +} from "../plugins/plugin-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; @@ -206,13 +212,47 @@ export function removeAgentBindings( } function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string { - const plugin = getChannelPlugin(provider); + const plugin = getBindingChannelPlugin(provider); if (!plugin) { return DEFAULT_ACCOUNT_ID; } return resolveChannelDefaultAccountId({ plugin, cfg }); } +function listManifestChannelIds(config: OpenClawConfig): Set { + const index = loadPluginRegistrySnapshot({ + config, + env: process.env, + }); + return new Set( + listPluginContributionIds({ + index, + contribution: "channels", + includeDisabled: true, + config, + }), + ); +} + +function normalizeBindingChannelId( + raw: string | undefined, + config: OpenClawConfig, +): ChannelId | null { + const bundled = normalizeBundledChannelId(raw); + if (bundled) { + return bundled; + } + const normalized = normalizeOptionalString(raw)?.toLowerCase(); + if (!normalized) { + return null; + } + return listManifestChannelIds(config).has(normalized) ? normalized : null; +} + +function getBindingChannelPlugin(channel: ChannelId) { + return getLoadedChannelPlugin(channel) ?? getBundledChannelSetupPlugin(channel); +} + function resolveBindingAccountId(params: { channel: ChannelId; config: OpenClawConfig; @@ -224,7 +264,7 @@ function resolveBindingAccountId(params: { return explicitAccountId; } - const plugin = getChannelPlugin(params.channel); + const plugin = getBindingChannelPlugin(params.channel); const pluginAccountId = plugin?.setup?.resolveBindingAccountId?.({ cfg: params.config, agentId: params.agentId, @@ -279,7 +319,7 @@ export function parseBindingSpecs(params: { continue; } const [channelRaw, accountRaw] = trimmed.split(":", 2); - const channel = normalizeChannelId(channelRaw); + const channel = normalizeBindingChannelId(channelRaw, params.config); if (!channel) { errors.push(`Unknown channel "${channelRaw}".`); continue;