fix: reserve legacy tool cli token

This commit is contained in:
Peter Steinberger
2026-05-02 04:06:34 +01:00
parent 1dd5fea759
commit 82a8006f77
8 changed files with 43 additions and 11 deletions

View File

@@ -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.

View File

@@ -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" },

View File

@@ -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");
});

View File

@@ -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"],

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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",