diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db9977b46c..de830b1e9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI: lazy-load model, plugin, and device runtime helpers and keep channel option help on generated startup metadata or generic fallback text so parent/help output renders without importing those runtime paths. +- CLI: route `plugins list --json` through the parsed command fast path and cover it in response budgets so plugin JSON inventory avoids full CLI registration work. - Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong. - Memory/daily-files: widen the daily-memory file matcher used by Dreaming, rem-backfill, rem-harness, the doctor sweep, and short-term promotion so `memory/YYYY-MM-DD-.md` files written by the bundled session-memory hook (and any future slugged variants) are discovered alongside the date-only `memory/YYYY-MM-DD.md` shape. Date extraction still uses the leading `YYYY-MM-DD` capture group, so per-day ingestion/promotion semantics are unchanged for existing date-only files; slugged files now flow through the same paths instead of being silently skipped. Fixes #69536. Thanks @jack-stormentswe. - Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang. diff --git a/scripts/bench-cli-startup.ts b/scripts/bench-cli-startup.ts index 96b8bc85918..434041aff81 100644 --- a/scripts/bench-cli-startup.ts +++ b/scripts/bench-cli-startup.ts @@ -161,6 +161,14 @@ const COMMAND_CASES: readonly CommandCase[] = [ firstOutputBudgetMs: 2_500, exitBudgetMs: 6_000, }, + { + id: "pluginsListJson", + name: "plugins list --json", + args: ["plugins", "list", "--json"], + presets: ["response", "real"], + firstOutputBudgetMs: 2_500, + exitBudgetMs: 6_000, + }, { id: "gatewayHelp", name: "gateway --help", diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index ad7acb728f7..f9fd5a26a96 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -27,7 +27,8 @@ type CliRoutedCommandId = | "tasks-list" | "tasks-audit" | "channels-list" - | "channels-status"; + | "channels-status" + | "plugins-list"; export type CliCommandPathPolicy = { bypassConfigGuard: boolean; @@ -307,6 +308,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ exact: true, policy: { hideBanner: true }, }, + { + commandPath: ["plugins", "list"], + exact: true, + policy: { ensureCliPath: false, loadPlugins: "never", networkProxy: "bypass" }, + route: { id: "plugins-list" }, + }, { commandPath: ["onboard"], exact: true, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index b47f7bdc4cd..7cfee401bda 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -203,9 +203,13 @@ describe("command-path-policy", () => { loadPlugins: "never", hideBanner: true, }); + expectResolvedPolicy(["plugins", "list"], { + ensureCliPath: false, + loadPlugins: "never", + networkProxy: "bypass", + }); for (const commandPath of [ ["plugins", "install"], - ["plugins", "list"], ["plugins", "inspect"], ["plugins", "registry"], ["plugins", "doctor"], diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index fbb5d42c87d..9132896e9db 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -272,6 +272,24 @@ export function parseChannelsStatusRouteArgs(argv: string[]) { }; } +export function parsePluginsListRouteArgs(argv: string[]) { + if (!hasFlag(argv, "--json")) { + return null; + } + const positionals = getCommandPositionalsWithRootOptions(argv, { + commandPath: ["plugins", "list"], + booleanFlags: ["--json", "--enabled", "--verbose"], + }); + if (!positionals || positionals.length !== 0) { + return null; + } + return { + json: true as const, + enabled: hasFlag(argv, "--enabled"), + verbose: hasFlag(argv, "--verbose"), + }; +} + function parseTasksListRouteArgsForCommandPath(argv: string[], commandPath: string[]) { if (!hasFlag(argv, "--json")) { return null; diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts index 3d49388e6fc..ff4ee4fc78f 100644 --- a/src/cli/program/routed-command-definitions.ts +++ b/src/cli/program/routed-command-definitions.ts @@ -10,6 +10,7 @@ import { parseHealthRouteArgs, parseModelsListRouteArgs, parseModelsStatusRouteArgs, + parsePluginsListRouteArgs, parseSessionsRouteArgs, parseStatusRouteArgs, parseTasksAuditRouteArgs, @@ -164,4 +165,11 @@ export const routedCommandDefinitions = { await channelsStatusCommand(args, defaultRuntime); }, }), + "plugins-list": defineRoutedCommand({ + parseArgs: parsePluginsListRouteArgs, + runParsedArgs: async (args) => { + const { runPluginsListCommand } = await import("../plugins-list-command.js"); + await runPluginsListCommand(args, defaultRuntime); + }, + }), }; diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index bc17d8fb560..065f4f30a12 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -13,6 +13,8 @@ const tasksAuditJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); const channelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const channelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); const agentsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const runPluginsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const pluginsCliLoadedMock = vi.hoisted(() => vi.fn()); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -55,6 +57,17 @@ vi.mock("../../commands/agents.js", () => ({ agentsListCommand: agentsListCommandMock, })); +vi.mock("../plugins-list-command.js", () => ({ + runPluginsListCommand: runPluginsListCommandMock, +})); + +vi.mock("../plugins-cli.js", () => { + pluginsCliLoadedMock(); + return { + registerPluginsCli: vi.fn(), + }; +}); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -150,6 +163,29 @@ describe("program routes", () => { ); }); + it("routes plugins list JSON without importing the full plugins CLI", async () => { + const route = expectRoute(["plugins", "list"]); + expect(route.loadPlugins).toBeUndefined(); + expect(route.canRun?.(["node", "openclaw", "plugins", "list"])).toBe(false); + + await expect( + route.run(["node", "openclaw", "plugins", "list", "--json", "--enabled", "--verbose"]), + ).resolves.toBe(true); + + expect(runPluginsListCommandMock).toHaveBeenCalledWith( + { json: true, enabled: true, verbose: true }, + defaultRuntime, + ); + expect(pluginsCliLoadedMock).not.toHaveBeenCalled(); + }); + + it("returns false for plugins list JSON route with unsupported arguments", async () => { + await expectRunFalse( + ["plugins", "list"], + ["node", "openclaw", "plugins", "list", "--json", "--wat"], + ); + }); + it("matches gateway status route without plugin preload", () => { const route = expectRoute(["gateway", "status"]); expect(route.loadPlugins).toBeUndefined();