fix(cli): avoid plugin preload for agent bindings

This commit is contained in:
Peter Steinberger
2026-04-25 22:38:22 +01:00
parent 9a2dfe0c7e
commit 265b97bbba
6 changed files with 138 additions and 12 deletions

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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<ChannelPlugin, "id" | "meta" | "capabilities" | "config"> & {
setup?: Pick<NonNullable<ChannelPlugin["setup"]>, "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,

View File

@@ -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<string> {
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;