fix(cli): keep tools rpc namespace off plugin startup

This commit is contained in:
Peter Steinberger
2026-04-29 13:46:53 +01:00
parent 88101e81ef
commit 390a7598c9
8 changed files with 53 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/models: restore provider-filtered `models list --all --provider <id>` 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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