diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4c6f2060f..51e7673cd7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index a04b7a01b90..a970430dc6c 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -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" }, + }, ]; diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index ab02cff17dc..5c7fc1d2a87 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -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, diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 75c1cb293e8..daa0183937b 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -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", () => { diff --git a/src/cli/program/message/helpers.test.ts b/src/cli/program/message/helpers.test.ts index de167df325f..0b20ecf1f0b 100644 --- a/src/cli/program/message/helpers.test.ts +++ b/src/cli/program/message/helpers.test.ts @@ -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); }); diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index dfb03b333f0..f14389727e1 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -122,6 +122,7 @@ describe("registerPreActionHooks", () => { .command("agent") .requiredOption("-m, --message ") .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(); diff --git a/src/commands/channels.logs.test.ts b/src/commands/channels.logs.test.ts new file mode 100644 index 00000000000..571aadc75f4 --- /dev/null +++ b/src/commands/channels.logs.test.ts @@ -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"]); + }); +}); diff --git a/src/commands/channels/logs.ts b/src/commands/channels/logs.ts index 3d37eec16b8..6c60c703309 100644 --- a/src/commands/channels/logs.ts +++ b/src/commands/channels/logs.ts @@ -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; const DEFAULT_LIMIT = 200; const MAX_BYTES = 1_000_000; -const getChannelSet = () => - new Set([...listChannelPlugins().map((plugin) => plugin.id), "all"]); +function listManifestChannelIds(): Set { + 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, channel: string) {