fix(cli): trim plugin preloads for setup-safe commands

This commit is contained in:
Peter Steinberger
2026-04-25 23:05:45 +01:00
parent 8d08e86f42
commit cf303b3101
8 changed files with 176 additions and 14 deletions

View File

@@ -64,6 +64,9 @@ Docs: https://docs.openclaw.ai
- CLI/gateway: keep diagnostic probes from creating first-time read-only device
pairings, while still reusing cached device tokens for detailed read probes.
Fixes #71766. Thanks @SunboZ.
- CLI/plugins: keep `message` startup, `channels logs`, `agents delete`, and
`agents set-identity` off broad plugin preloading; message delivery still
loads plugins when the action actually runs.
- 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.

View File

@@ -37,7 +37,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false },
},
{ commandPath: ["agent"], policy: { loadPlugins: "always" } },
{ commandPath: ["message"], policy: { loadPlugins: "always" } },
{ commandPath: ["message"], policy: { loadPlugins: "never" } },
{ commandPath: ["channels"], policy: { loadPlugins: "always" } },
{ commandPath: ["directory"], policy: { loadPlugins: "always" } },
{ commandPath: ["agents"], policy: { loadPlugins: "always" } },
@@ -56,6 +56,16 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
policy: { loadPlugins: "never" },
},
{
commandPath: ["agents", "set-identity"],
exact: true,
policy: { loadPlugins: "never" },
},
{
commandPath: ["agents", "delete"],
exact: true,
policy: { loadPlugins: "never" },
},
{ commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } },
{
commandPath: ["status"],
@@ -168,4 +178,9 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: { loadPlugins: "never" },
route: { id: "channels-list" },
},
{
commandPath: ["channels", "logs"],
exact: true,
policy: { loadPlugins: "never" },
},
];

View File

@@ -41,13 +41,22 @@ describe("command-path-policy", () => {
hideBanner: false,
ensureCliPath: true,
});
expect(resolveCliCommandPathPolicy(["channels", "logs"])).toEqual({
bypassConfigGuard: false,
routeConfigGuard: "never",
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
});
});
it("keeps agent binding commands on config-only startup", () => {
it("keeps config-only agent commands on config-only startup", () => {
for (const commandPath of [
["agents", "bind"],
["agents", "bindings"],
["agents", "unbind"],
["agents", "set-identity"],
["agents", "delete"],
]) {
expect(resolveCliCommandPathPolicy(commandPath)).toEqual({
bypassConfigGuard: false,

View File

@@ -74,6 +74,24 @@ describe("command-startup-policy", () => {
jsonOutputMode: false,
}),
).toBe(false);
expect(
shouldLoadPluginsForCommandPath({
commandPath: ["channels", "logs"],
jsonOutputMode: false,
}),
).toBe(false);
expect(
shouldLoadPluginsForCommandPath({
commandPath: ["message", "send"],
jsonOutputMode: false,
}),
).toBe(false);
expect(
shouldLoadPluginsForCommandPath({
commandPath: ["message", "send"],
jsonOutputMode: true,
}),
).toBe(false);
expect(
shouldLoadPluginsForCommandPath({
commandPath: ["agents", "list"],
@@ -107,6 +125,18 @@ describe("command-startup-policy", () => {
jsonOutputMode: false,
}),
).toBe(false);
expect(
shouldLoadPluginsForCommandPath({
commandPath: ["agents", "set-identity"],
jsonOutputMode: false,
}),
).toBe(false);
expect(
shouldLoadPluginsForCommandPath({
commandPath: ["agents", "delete"],
jsonOutputMode: true,
}),
).toBe(false);
});
it("matches banner suppression policy", () => {

View File

@@ -13,6 +13,7 @@ vi.mock("../../../globals.js", () => ({
vi.mock("../../plugin-registry.js", () => ({
ensurePluginRegistryLoaded: vi.fn(),
}));
const { ensurePluginRegistryLoaded } = await import("../../plugin-registry.js");
const hasHooksMock = vi.fn((_hookName: string) => false);
const runGatewayStopMock = vi.fn(
@@ -95,6 +96,7 @@ describe("runMessageAction", () => {
it("calls exit(0) after successful message delivery", async () => {
await runSendAction();
expect(ensurePluginRegistryLoaded).toHaveBeenCalledOnce();
expect(exitMock).toHaveBeenCalledOnce();
expect(exitMock).toHaveBeenCalledWith(0);
});

View File

@@ -122,6 +122,7 @@ describe("registerPreActionHooks", () => {
.command("agent")
.requiredOption("-m, --message <text>")
.option("--local")
.option("--json")
.action(() => {});
program
.command("status")
@@ -214,15 +215,15 @@ describe("registerPreActionHooks", () => {
vi.clearAllMocks();
await runPreAction({
parseArgv: ["message", "send"],
processArgv: ["node", "openclaw", "message", "send"],
parseArgv: ["agents", "list"],
processArgv: ["node", "openclaw", "agents", "list"],
});
expect(setVerboseMock).toHaveBeenCalledWith(false);
expect(process.env.NODE_NO_WARNINGS).toBe("1");
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["message", "send"],
commandPath: ["agents", "list"],
});
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" });
processTitleSetSpy.mockRestore();
@@ -393,8 +394,8 @@ describe("registerPreActionHooks", () => {
it("routes logs to stderr in --json mode so stdout stays clean", async () => {
await runPreAction({
parseArgv: ["message", "send"],
processArgv: ["node", "openclaw", "message", "send", "--json"],
parseArgv: ["agent"],
processArgv: ["node", "openclaw", "agent", "--message", "hi", "--json"],
});
expect(routeLogsToStderrMock).toHaveBeenCalledOnce();
@@ -473,8 +474,8 @@ describe("registerPreActionHooks", () => {
});
await runPreAction({
parseArgv: ["message", "send"],
processArgv: ["node", "openclaw", "message", "send", "--json"],
parseArgv: ["agent"],
processArgv: ["node", "openclaw", "agent", "--message", "hi", "--json"],
});
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();

View File

@@ -0,0 +1,84 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setLoggerOverride } from "../logging.js";
import { createTestRuntime } from "./test-runtime-config-helpers.js";
const pluginRegistryMocks = vi.hoisted(() => ({
loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })),
listPluginContributionIds: vi.fn(() => ["external-chat"]),
}));
vi.mock("../plugins/plugin-registry.js", () => ({
loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot,
listPluginContributionIds: pluginRegistryMocks.listPluginContributionIds,
}));
vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins: vi.fn(() => {
throw new Error("channels logs must not load channel plugins");
}),
}));
import { channelsLogsCommand } from "./channels/logs.js";
const runtime = createTestRuntime();
function logLine(params: { module: string; message: string }) {
return JSON.stringify({
time: "2026-04-25T12:00:00.000Z",
0: params.message,
_meta: {
logLevelName: "INFO",
name: JSON.stringify({ module: params.module }),
},
});
}
describe("channelsLogsCommand", () => {
let tempDir: string;
let logPath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-channels-logs-"));
logPath = path.join(tempDir, "openclaw.log");
setLoggerOverride({ file: logPath });
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
pluginRegistryMocks.loadPluginRegistrySnapshot.mockClear();
pluginRegistryMocks.listPluginContributionIds.mockClear();
});
afterEach(async () => {
setLoggerOverride(null);
await fs.rm(tempDir, { recursive: true, force: true });
});
it("filters external plugin channel logs from the persisted manifest registry", async () => {
await fs.writeFile(
logPath,
[
logLine({ module: "gateway/channels/external-chat/send", message: "external sent" }),
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
].join("\n"),
);
await channelsLogsCommand({ channel: "external-chat", json: true }, runtime);
expect(pluginRegistryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledOnce();
expect(pluginRegistryMocks.listPluginContributionIds).toHaveBeenCalledWith(
expect.objectContaining({
contribution: "channels",
includeDisabled: true,
}),
);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])) as {
channel: string;
lines: Array<{ message: string }>;
};
expect(payload.channel).toBe("external-chat");
expect(payload.lines.map((line) => line.message)).toEqual(["external sent"]);
});
});

View File

@@ -1,7 +1,11 @@
import fs from "node:fs/promises";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { normalizeChannelId as normalizeBundledChannelId } from "../../channels/registry.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { parseLogLine } from "../../logging/parse-log-line.js";
import {
listPluginContributionIds,
loadPluginRegistrySnapshot,
} from "../../plugins/plugin-registry.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { theme } from "../../terminal/theme.js";
@@ -17,15 +21,29 @@ type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000;
const getChannelSet = () =>
new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
function listManifestChannelIds(): Set<string> {
const index = loadPluginRegistrySnapshot({
env: process.env,
});
return new Set(
listPluginContributionIds({
index,
contribution: "channels",
includeDisabled: true,
}),
);
}
function parseChannelFilter(raw?: string) {
const trimmed = normalizeLowercaseStringOrEmpty(raw);
if (!trimmed) {
if (!trimmed || trimmed === "all") {
return "all";
}
return getChannelSet().has(trimmed) ? trimmed : "all";
const bundled = normalizeBundledChannelId(trimmed);
if (bundled) {
return bundled;
}
return listManifestChannelIds().has(trimmed) ? trimmed : "all";
}
function matchesChannel(line: NonNullable<LogLine>, channel: string) {