fix(cli): clarify plugin tool command mistakes

Summary:
- clarify CLI diagnostics when an unknown subcommand is actually a plugin agent tool
- route early proxy-preflight misses through the same policy helper
- refresh bundled sidecar update fixtures for current package ownership

Verification:
- pnpm test src/cli/run-main.test.ts src/cli/run-main.exit.test.ts src/plugins/manifest-command-aliases.test.ts
- pnpm test src/infra/update-global.test.ts src/infra/update-runner.test.ts
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/cli/run-main-policy.ts src/cli/run-main.ts src/cli/run-main.test.ts src/cli/run-main.exit.test.ts src/plugins/manifest-command-aliases.ts src/plugins/manifest-command-aliases.runtime.ts src/plugins/manifest-command-aliases.test.ts
- pnpm build
- live temp lossless-claw manifest: pnpm openclaw lcm_recent emits the agent-tool diagnostic and no plugins.allow suggestion

Co-authored-by: 100yenadmin <100yenadmin+agent-77214@100yen.org>
This commit is contained in:
Peter Steinberger
2026-05-09 03:11:44 -04:00
committed by GitHub
parent 678c41a222
commit 0c50957dbb
10 changed files with 369 additions and 18 deletions

View File

@@ -1,8 +1,10 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveManifestCommandAliasOwnerInRegistry,
resolveManifestToolOwnerInRegistry,
type PluginManifestCommandAliasRecord,
type PluginManifestCommandAliasRegistry,
type PluginManifestToolOwnerRecord,
} from "../plugins/manifest-command-aliases.js";
import {
normalizeLowercaseStringOrEmpty,
@@ -105,6 +107,11 @@ export function resolveMissingPluginCommandMessage(
config?: OpenClawConfig;
registry?: PluginManifestCommandAliasRegistry;
}) => PluginManifestCommandAliasRecord | undefined;
resolveToolOwner?: (params: {
toolName: string | undefined;
config?: OpenClawConfig;
registry?: PluginManifestCommandAliasRegistry;
}) => PluginManifestToolOwnerRecord | undefined;
},
): string | null {
const normalizedPluginId = normalizeLowercaseStringOrEmpty(pluginId);
@@ -171,6 +178,47 @@ export function resolveMissingPluginCommandMessage(
return null;
}
const toolOwner = options?.registry
? resolveManifestToolOwnerInRegistry({
toolName: normalizedPluginId,
registry: options.registry,
})
: options?.resolveToolOwner?.({
toolName: normalizedPluginId,
config,
...(options?.registry ? { registry: options.registry } : {}),
});
if (toolOwner) {
// Apply plugins.allow / plugins.entries[X].enabled to the owning plugin so
// a disabled/denied plugin's manifest-declared tool name does not get a
// false attribution. The runtime resolver
// (resolveManifestToolOwner) already filters by control-plane availability,
// but pure-registry callers and any future ones still need this guard.
const ownerEnabled =
config?.plugins?.entries?.[toolOwner.pluginId]?.enabled !== false &&
(allow.length === 0 || allow.includes(toolOwner.pluginId));
if (ownerEnabled) {
// Per-account / per-tool runtime gates (e.g. Feishu's
// channels.feishu.enabled / tools.<x> toggles) are not declarable as
// manifest configSignals, so a positive manifest-availability signal
// proves "could be loaded if config permits", not "currently registered".
// Soften the wording when the runtime resolver could only prove
// manifest-level ownership.
if (toolOwner.availability === "manifest-only") {
return (
`"${normalizedPluginId}" may be provided by the "${toolOwner.pluginId}" plugin ` +
`as an agent tool, not a CLI subcommand. ` +
"Run `openclaw --help` to see available CLI subcommands."
);
}
return (
`"${normalizedPluginId}" is an agent tool available from the "${toolOwner.pluginId}" plugin, ` +
`not a CLI subcommand. Use it from an agent turn (model tool-use), not the CLI. ` +
"Run `openclaw --help` to see available CLI subcommands."
);
}
}
if (allow.length > 0 && !allow.includes(normalizedPluginId)) {
if (parentPluginId && allow.includes(parentPluginId)) {
return null;

View File

@@ -23,6 +23,8 @@ const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(async () => ({})));
const resolvePluginCliRootOwnerIdsMock = vi.hoisted(() => vi.fn());
const resolveManifestCommandAliasOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestToolOwnerMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn());
@@ -170,6 +172,11 @@ vi.mock("../plugins/cli-registry-loader.js", () => ({
resolvePluginCliRootOwnerIds: resolvePluginCliRootOwnerIdsMock,
}));
vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({
resolveManifestCommandAliasOwner: resolveManifestCommandAliasOwnerMock,
resolveManifestToolOwner: resolveManifestToolOwnerMock,
}));
vi.mock("../terminal/restore.js", () => ({
restoreTerminalState: restoreTerminalStateMock,
}));
@@ -237,6 +244,8 @@ describe("runCli exit behavior", () => {
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "googlemeet" ? ["google-meet"] : [],
);
resolveManifestCommandAliasOwnerMock.mockReturnValue(undefined);
resolveManifestToolOwnerMock.mockReturnValue(undefined);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
delete process.env.OPENCLAW_HIDE_BANNER;
});
@@ -462,6 +471,22 @@ describe("runCli exit behavior", () => {
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("reports plugin tool command mistakes before proxy startup", async () => {
resolveManifestToolOwnerMock.mockReturnValueOnce({
toolName: "lcm_recent",
pluginId: "lossless-claw",
availability: "loaded",
});
await expect(runCli(["node", "openclaw", "lcm_recent"])).rejects.toThrow(
'"lcm_recent" is an agent tool available from the "lossless-claw" plugin',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("does not install the env proxy dispatcher for bypassed skills inspection commands", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
tryRouteCliMock.mockResolvedValueOnce(true);

View File

@@ -31,6 +31,15 @@ const memoryCoreCommandAliasRegistry: PluginManifestCommandAliasRegistry = {
],
};
const losslessClawToolRegistry: PluginManifestCommandAliasRegistry = {
plugins: [
{
id: "lossless-claw",
contracts: { tools: ["lcm_recent", "lcm_search"] },
},
],
};
describe("isGatewayRunFastPathArgv", () => {
it("matches only plain gateway foreground starts without root options or help", () => {
expect(isGatewayRunFastPathArgv(["node", "openclaw", "gateway"])).toBe(true);
@@ -366,4 +375,101 @@ describe("resolveMissingPluginCommandMessage", () => {
expect(message).toContain('"memory-wiki"');
expect(message).toContain("plugins.allow");
});
it("identifies an agent tool name and points the user at model tool-use", () => {
const message = resolveMissingPluginCommandMessage(
"lcm_recent",
{
plugins: {
allow: ["lossless-claw"],
},
},
{ registry: losslessClawToolRegistry },
);
expect(message).not.toBeNull();
expect(message).toContain('"lcm_recent"');
expect(message).toContain('"lossless-claw"');
expect(message).toContain("agent tool");
expect(message).not.toContain("plugins.allow");
});
it("matches agent tool names case-insensitively", () => {
const message = resolveMissingPluginCommandMessage("LCM_Recent", undefined, {
registry: losslessClawToolRegistry,
});
expect(message).not.toBeNull();
expect(message).toContain("agent tool");
expect(message).toContain('"lossless-claw"');
});
it("preserves the plugins.allow suggestion when the unknown name is not a plugin tool", () => {
const message = resolveMissingPluginCommandMessage(
"totally-unknown",
{
plugins: {
allow: ["quietchat"],
},
},
{ registry: losslessClawToolRegistry },
);
expect(message).not.toBeNull();
expect(message).toContain('`plugins.allow` excludes "totally-unknown"');
});
it("does not attribute a tool to an owning plugin excluded by plugins.allow", () => {
// The owning plugin is denied via plugins.allow, so the manifest-declared
// tool is not available through the owning plugin. Fall through to the
// standard plugins.allow message instead of falsely attributing it.
const message = resolveMissingPluginCommandMessage(
"lcm_recent",
{
plugins: {
allow: ["quietchat"],
},
},
{ registry: losslessClawToolRegistry },
);
expect(message).not.toBeNull();
expect(message).not.toContain("agent tool available");
expect(message).toContain('`plugins.allow` excludes "lcm_recent"');
});
it("does not attribute a tool to an owning plugin disabled via plugins.entries", () => {
const message = resolveMissingPluginCommandMessage(
"lcm_recent",
{
plugins: {
entries: {
"lossless-claw": { enabled: false },
},
},
},
{ registry: losslessClawToolRegistry },
);
// entries.<id>.enabled = false on the OWNING plugin invalidates the
// plugin-tool attribution. With no allow filter on the bare name the
// diagnostic returns null (no actionable message); callers handle that
// as "not a recognised plugin command".
expect(message).toBeNull();
});
it("uses softer 'may be provided by' wording for manifest-only availability", () => {
// Some runtime gates (per-account enabled, per-tool toggles in the Feishu
// family etc.) cannot be expressed as manifest configSignals, so the
// runtime resolver reports availability: "manifest-only" when ownership is
// only manifest-provable. The diagnostic must avoid asserting "registered
// by" in that case.
const manifestOnlyOwner = {
toolName: "feishu_chat",
pluginId: "feishu",
availability: "manifest-only" as const,
};
const message = resolveMissingPluginCommandMessage("feishu_chat", undefined, {
resolveToolOwner: () => manifestOnlyOwner,
});
expect(message).not.toBeNull();
expect(message).toContain("may be provided by");
expect(message).toContain('"feishu"');
expect(message).not.toContain("registered by");
});
});

View File

@@ -338,6 +338,21 @@ async function resolveUnownedCliPrimary(params: {
return primary;
}
async function resolveUnownedCliPrimaryMessage(params: {
primary: string;
config: OpenClawConfig;
}): Promise<string> {
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
return (
resolveMissingPluginCommandMessageFromPolicy(params.primary, params.config, {
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
resolveToolOwner: resolveManifestToolOwner,
}) ??
`Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`
);
}
async function bootstrapCliProxyCaptureAndDispatcher(
startupTrace: ReturnType<typeof createGatewayCliMainStartupTrace>,
options: { ensureDispatcher?: boolean } = {},
@@ -432,9 +447,7 @@ export async function runCli(argv: string[] = process.argv) {
const config = await readBestEffortCliConfig();
const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config });
if (unownedPrimary) {
throw new Error(
`Unknown command: openclaw ${unownedPrimary}. No built-in command or plugin CLI metadata owns "${unownedPrimary}".`,
);
throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config }));
}
const { startProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
proxyHandle = await startProxy(config?.proxy ?? undefined);
@@ -673,13 +686,14 @@ export async function runCli(argv: string[] = process.argv) {
(command) => command.name() === primary || command.aliases().includes(primary),
)
) {
const { resolveManifestCommandAliasOwner } =
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
const missingPluginCommandMessage = resolveMissingPluginCommandMessageFromPolicy(
primary,
config,
{
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
resolveToolOwner: resolveManifestToolOwner,
},
);
if (missingPluginCommandMessage) {