From 390a7598c952bda5db6a423e02db66c9db351f30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 13:46:53 +0100 Subject: [PATCH] fix(cli): keep tools rpc namespace off plugin startup --- CHANGELOG.md | 1 + src/cli/command-catalog.ts | 4 ++++ src/cli/command-path-policy.test.ts | 1 + src/cli/command-registration-policy.test.ts | 14 +++++++++++ src/cli/command-registration-policy.ts | 5 ++++ src/cli/command-startup-policy.test.ts | 1 + src/cli/run-main.exit.test.ts | 26 +++++++++++++++++++++ src/cli/run-main.test.ts | 1 + 8 files changed, 53 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ead782038..33edcd683f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/models: restore provider-filtered `models list --all --provider ` rows for providers without manifest/static catalog coverage, including Anthropic and Amazon Bedrock, while keeping the compatibility fallback off expensive availability and resolver paths. Thanks @shakkernerd. +- CLI/tools: keep the Gateway `tools.*` RPC namespace out of plugin command discovery and managed proxy startup, so stray commands like `openclaw tools effective` fail quickly instead of cold-loading plugin metadata. Refs #73477. Thanks @oromeis. - CLI/status: keep default text `openclaw status --usage` on metadata-only channel scans unless `--deep` or `--all` is set, and send stray `openclaw tools --help` through the precomputed root-help fast path so latency-triage commands avoid plugin/runtime cold loads before printing. Refs #73477 and #74220. Thanks @oromeis and @NianJiuZst. - Plugins/runtime-deps: memoize packaged bundled runtime dist-mirror preparation after the first successful pass while keeping source-checkout mirrors refreshable, so constrained Docker/VPS installs avoid repeated root scans before chat turns. Refs #73428, #73421, #73532, and #73477. Thanks @Dimaoggg, @oromeis, @oadiazp, @jmfraga, @bstanbury, @antoniusfelix, and @jkobject. - Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie. diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 3407ebf406c..a591478a7ed 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -211,6 +211,10 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ }, route: { id: "tasks-list" }, }, + { + commandPath: ["tools"], + policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" }, + }, { commandPath: ["acp"], policy: { networkProxy: "bypass" } }, { commandPath: ["approvals"], policy: { networkProxy: "bypass" } }, { commandPath: ["backup"], policy: { bypassConfigGuard: true, networkProxy: "bypass" } }, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 9a2f4c0a2be..1d682b59d45 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -157,6 +157,7 @@ describe("command-path-policy", () => { expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "googlemeet", "login"])).toBe( "default", ); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "tools", "effective"])).toBe("bypass"); }); it("resolves static network proxy bypass policies from the catalog", () => { diff --git a/src/cli/command-registration-policy.test.ts b/src/cli/command-registration-policy.test.ts index 8c6fe25bac4..0ac206e3858 100644 --- a/src/cli/command-registration-policy.test.ts +++ b/src/cli/command-registration-policy.test.ts @@ -50,6 +50,20 @@ describe("command-registration-policy", () => { hasBuiltinPrimary: false, }), ).toBe(false); + expect( + shouldSkipPluginCommandRegistration({ + argv: ["node", "openclaw", "tools", "effective"], + primary: "tools", + hasBuiltinPrimary: false, + }), + ).toBe(true); + expect( + shouldSkipPluginCommandRegistration({ + argv: ["node", "openclaw", "googlemeet", "login"], + primary: "googlemeet", + hasBuiltinPrimary: false, + }), + ).toBe(false); }); it("matches lazy subcommand registration policy", () => { diff --git a/src/cli/command-registration-policy.ts b/src/cli/command-registration-policy.ts index efef8b7844a..cfc89179d6f 100644 --- a/src/cli/command-registration-policy.ts +++ b/src/cli/command-registration-policy.ts @@ -1,6 +1,8 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; +const RESERVED_NON_PLUGIN_COMMAND_ROOTS = new Set(["tools"]); + export function shouldRegisterPrimaryCommandOnly(argv: string[]): boolean { const invocation = resolveCliArgvInvocation(argv); return invocation.primary !== null || !invocation.hasHelpOrVersion; @@ -21,6 +23,9 @@ export function shouldSkipPluginCommandRegistration(params: { if (!params.primary) { return invocation.hasHelpOrVersion; } + if (RESERVED_NON_PLUGIN_COMMAND_ROOTS.has(params.primary)) { + return true; + } return false; } diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 1b48c2ef845..4479cfced43 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -177,6 +177,7 @@ describe("command-startup-policy", () => { expect(shouldEnsureCliPathForCommandPath(["sessions"])).toBe(false); expect(shouldEnsureCliPathForCommandPath(["config", "get"])).toBe(false); expect(shouldEnsureCliPathForCommandPath(["models", "status"])).toBe(false); + expect(shouldEnsureCliPathForCommandPath(["tools", "effective"])).toBe(false); expect(shouldEnsureCliPathForCommandPath(["message", "send"])).toBe(true); expect(shouldEnsureCliPathForCommandPath([])).toBe(true); }); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index a3fdb1d3761..fef4ae8f560 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -19,6 +19,7 @@ const buildProgramMock = vi.hoisted(() => vi.fn()); const getProgramContextMock = vi.hoisted(() => vi.fn(() => null)); const registerCoreCliByNameMock = vi.hoisted(() => vi.fn()); const registerSubCliByNameMock = vi.hoisted(() => vi.fn()); +const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(async () => ({}))); const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false)); const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn()); @@ -151,6 +152,10 @@ vi.mock("./program/register.subclis.js", () => ({ registerSubCliByName: registerSubCliByNameMock, })); +vi.mock("../plugins/cli.js", () => ({ + registerPluginCliCommandsFromValidatedConfig: registerPluginCliCommandsFromValidatedConfigMock, +})); + vi.mock("../terminal/restore.js", () => ({ restoreTerminalState: restoreTerminalStateMock, })); @@ -358,6 +363,7 @@ describe("runCli exit behavior", () => { ["models list", ["node", "openclaw", "models", "list"]], ["models status without live probe", ["node", "openclaw", "models", "status"]], ["tasks list", ["node", "openclaw", "tasks", "list"]], + ["gateway tools namespace typo", ["node", "openclaw", "tools", "effective"]], ["migrate", ["node", "openclaw", "migrate"]], ])("skips managed proxy routing for %s", (_name, argv) => { expect(shouldStartProxyForCli(argv)).toBe(false); @@ -379,6 +385,26 @@ describe("runCli exit behavior", () => { expect(startProxyMock).toHaveBeenCalledWith(undefined); }); + it("keeps gateway tool RPC names out of plugin command discovery", async () => { + const parseAsync = vi.fn().mockResolvedValueOnce(undefined); + buildProgramMock.mockReturnValueOnce({ + commands: [], + parseAsync, + }); + + await runCli(["node", "openclaw", "tools", "effective"]); + + expect(startProxyMock).not.toHaveBeenCalled(); + expect(registerSubCliByNameMock).toHaveBeenCalledWith(expect.anything(), "tools", [ + "node", + "openclaw", + "tools", + "effective", + ]); + expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); + expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "tools", "effective"]); + }); + it("fails protected commands when managed proxy activation fails", async () => { startProxyMock.mockRejectedValueOnce(new Error("proxy: enabled but no HTTP proxy URL")); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 851e58a8543..0d8b69933f6 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -99,6 +99,7 @@ describe("shouldEnsureCliPath", () => { expect(shouldEnsureCliPath(["node", "openclaw", "sessions", "--json"])).toBe(false); expect(shouldEnsureCliPath(["node", "openclaw", "config", "get", "update"])).toBe(false); expect(shouldEnsureCliPath(["node", "openclaw", "models", "status", "--json"])).toBe(false); + expect(shouldEnsureCliPath(["node", "openclaw", "tools", "effective"])).toBe(false); }); it("keeps path bootstrap for mutating or unknown commands", () => {