From 82a8006f77109b786cfe25b6590d74c3878e58e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:06:34 +0100 Subject: [PATCH] fix: reserve legacy tool cli token --- CHANGELOG.md | 1 + src/cli/command-catalog.ts | 4 ++++ src/cli/command-path-policy.test.ts | 3 +++ src/cli/command-registration-policy.test.ts | 7 +++++++ src/cli/command-registration-policy.ts | 8 ++++++-- src/cli/run-main-policy.ts | 5 +++++ src/cli/run-main.exit.test.ts | 17 ++++++++--------- src/cli/run-main.test.ts | 9 +++++++++ 8 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 517e83c09ee..582cc46507f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Control UI/chat: keep live replies visible when a raw session alias such as `main` sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes. +- CLI: stop treating the legacy singular `openclaw tool ...` token as a plugin id under restrictive `plugins.allow`, so it falls through as a normal unknown/reserved command instead of suggesting a stale allowlist entry. Fixes #64732. Thanks @efe-arv, @SweetSophia, and @hashtag1974. - Media: write inbound media buffers through same-directory temp files before rename, so failed disk writes do not leave zero-byte artifacts for later voice transcription. Fixes #55966. Thanks @OpenCodeEngineer. - TTS/Telegram: keep trusted local audio generated by the TTS tool queued for voice-note delivery even when the run-level built-in tool list omits the raw `tts` name. Fixes #74752. Thanks @Loveworld3033 and @andyliu. - TTS: require explicit user or config audio intent for the agent speech tool so dashboard chats stay text unless audio is requested. Fixes #69777. Thanks @alexandre-leng. diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 3d1d35c6ac4..ad7acb728f7 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -241,6 +241,10 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ }, route: { id: "tasks-list" }, }, + { + commandPath: ["tool"], + policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" }, + }, { commandPath: ["tools"], policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" }, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 66d766a0c09..bd95dbc224b 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -151,6 +151,9 @@ describe("command-path-policy", () => { expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "googlemeet", "login"])).toBe( "default", ); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "tool", "image_generate"])).toBe( + "bypass", + ); expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "tools", "effective"])).toBe("bypass"); }); diff --git a/src/cli/command-registration-policy.test.ts b/src/cli/command-registration-policy.test.ts index 0ac206e3858..23992545d8d 100644 --- a/src/cli/command-registration-policy.test.ts +++ b/src/cli/command-registration-policy.test.ts @@ -50,6 +50,13 @@ describe("command-registration-policy", () => { hasBuiltinPrimary: false, }), ).toBe(false); + expect( + shouldSkipPluginCommandRegistration({ + argv: ["node", "openclaw", "tool", "image_generate"], + primary: "tool", + hasBuiltinPrimary: false, + }), + ).toBe(true); expect( shouldSkipPluginCommandRegistration({ argv: ["node", "openclaw", "tools", "effective"], diff --git a/src/cli/command-registration-policy.ts b/src/cli/command-registration-policy.ts index cfc89179d6f..673f59c2d28 100644 --- a/src/cli/command-registration-policy.ts +++ b/src/cli/command-registration-policy.ts @@ -1,7 +1,11 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; -const RESERVED_NON_PLUGIN_COMMAND_ROOTS = new Set(["tools"]); +const RESERVED_NON_PLUGIN_COMMAND_ROOTS = new Set(["tool", "tools"]); + +export function isReservedNonPluginCommandRoot(primary: string | null | undefined): boolean { + return typeof primary === "string" && RESERVED_NON_PLUGIN_COMMAND_ROOTS.has(primary); +} export function shouldRegisterPrimaryCommandOnly(argv: string[]): boolean { const invocation = resolveCliArgvInvocation(argv); @@ -23,7 +27,7 @@ export function shouldSkipPluginCommandRegistration(params: { if (!params.primary) { return invocation.hasHelpOrVersion; } - if (RESERVED_NON_PLUGIN_COMMAND_ROOTS.has(params.primary)) { + if (isReservedNonPluginCommandRoot(params.primary)) { return true; } return false; diff --git a/src/cli/run-main-policy.ts b/src/cli/run-main-policy.ts index 1414135442e..fb7eb34f2c2 100644 --- a/src/cli/run-main-policy.ts +++ b/src/cli/run-main-policy.ts @@ -13,6 +13,7 @@ import { resolveCliCommandPathPolicy, resolveCliNetworkProxyPolicy, } from "./command-path-policy.js"; +import { isReservedNonPluginCommandRoot } from "./command-registration-policy.js"; const ROOT_HELP_ALIASES = new Set(["tools"]); @@ -152,6 +153,10 @@ export function resolveMissingPluginCommandMessage( } } + if (isReservedNonPluginCommandRoot(normalizedPluginId)) { + return null; + } + if (allow.length > 0 && !allow.includes(normalizedPluginId)) { if (parentPluginId && allow.includes(parentPluginId)) { return null; diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 0a933f03270..63a858c8b96 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -363,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"]], + ["legacy singular tool namespace", ["node", "openclaw", "tool", "image_generate"]], ["gateway tools namespace typo", ["node", "openclaw", "tools", "effective"]], ["migrate", ["node", "openclaw", "migrate"]], ])("skips managed proxy routing for %s", (_name, argv) => { @@ -385,24 +386,22 @@ describe("runCli exit behavior", () => { expect(startProxyMock).toHaveBeenCalledWith(undefined); }); - it("keeps gateway tool RPC names out of plugin command discovery", async () => { + it.each([ + ["tool", ["node", "openclaw", "tool", "image_generate"]], + ["tools", ["node", "openclaw", "tools", "effective"]], + ])("keeps reserved %s command roots out of plugin command discovery", async (_name, argv) => { const parseAsync = vi.fn().mockResolvedValueOnce(undefined); buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync, }); - await runCli(["node", "openclaw", "tools", "effective"]); + await runCli(argv); expect(startProxyMock).not.toHaveBeenCalled(); - expect(registerSubCliByNameMock).toHaveBeenCalledWith(expect.anything(), "tools", [ - "node", - "openclaw", - "tools", - "effective", - ]); + expect(registerSubCliByNameMock).toHaveBeenCalledWith(expect.anything(), argv[2], argv); expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); - expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "tools", "effective"]); + expect(parseAsync).toHaveBeenCalledWith(argv); }); it("fails protected commands when managed proxy activation fails", async () => { diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 0d8b69933f6..1a319478ffc 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -213,6 +213,15 @@ describe("resolveMissingPluginCommandMessage", () => { ).toBeNull(); }); + it("does not classify reserved non-plugin command roots as plugin allowlist misses", () => { + const message = resolveMissingPluginCommandMessage("tool", { + plugins: { + allow: ["browser"], + }, + }); + expect(message).toBeNull(); + }); + it("explains that dreaming is a runtime slash command, not a CLI command", () => { const message = resolveMissingPluginCommandMessage( "dreaming",