mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 23:00:43 +00:00
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:
committed by
GitHub
parent
678c41a222
commit
0c50957dbb
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user