Skip to content

Commit ae62a99

Browse files
100yenadminclaude
authored andcommitted
fix(cli): clarify error when unknown subcommand is actually an agent tool name
When `openclaw <name>` does not match a CLI subcommand or a plugin id, the unknown-subcommand handler now first looks up <name> against the manifest plugin tool registry. If <name> is an agent tool declared by a plugin (e.g. `lcm_recent` from `lossless-claw`), emit a clear 'this is an agent tool, not a CLI subcommand' error pointing at model tool-use instead of the misleading `plugins.allow` suggestion. The previous error told the user to add the tool name to `plugins.allow`, but `plugins.allow` accepts plugin ids (not tool names), and config patches against it are rejected by the protected-config-paths guard. In the wild this sent an agent down 3 restart cycles trying to 'fix' config that was never the problem. Filtering: the runtime resolver consults the full manifest snapshot and filters through `isManifestPluginAvailableForControlPlane` (covering `plugins.allow`/`plugins.deny`/`plugins.entries[id].enabled`/installed index) plus per-tool `hasManifestToolAvailability` (covering `toolMetadata.configSignals` like Feishu's `appId`/`appSecret` gate). The diagnostic also re-checks `plugins.allow` and `plugins.entries[X].enabled` for the OWNING plugin before emitting, so denied/disabled plugins are not falsely attributed. Wording: when ownership is only manifest-provable (per-account `enabled` flags and per-tool toggles in the Feishu family are runtime-only and not declarable as configSignals, so the manifest is necessarily an over-approximation), emit a softer 'may be provided by the X plugin' message instead of asserting 'registered by'. The strong wording is reserved for the case where both control-plane availability and tool availability pass. Fall-throughs: if <name> is not a registered tool, the existing `plugins.allow` and `plugins.entries.<id>.enabled` suggestions are emitted unchanged. The new branch only fires when the tool-registry lookup matches AND the owning plugin passes availability filters. Closes #77214.
1 parent 544c046 commit ae62a99

7 files changed

Lines changed: 315 additions & 2 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ Docs: https://docs.openclaw.ai
183183

184184
### Fixes
185185

186+
- CLI/router: when `openclaw <name>` does not match a CLI subcommand, check the runtime plugin tool registry first so that names that are actually agent tools (e.g. `lcm_recent`) get a "this is an agent tool registered by the `<plugin>` plugin, not a CLI subcommand" error instead of the misleading suggestion to add the tool name to `plugins.allow`. Fixes #77214.
186187
- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context.
187188
- fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987.
188189
- fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987.

src/cli/run-main-policy.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
22
import {
33
resolveManifestCommandAliasOwnerInRegistry,
4+
resolveManifestToolOwnerInRegistry,
45
type PluginManifestCommandAliasRecord,
56
type PluginManifestCommandAliasRegistry,
7+
type PluginManifestToolOwnerRecord,
68
} from "../plugins/manifest-command-aliases.js";
79
import {
810
normalizeLowercaseStringOrEmpty,
@@ -105,6 +107,11 @@ export function resolveMissingPluginCommandMessage(
105107
config?: OpenClawConfig;
106108
registry?: PluginManifestCommandAliasRegistry;
107109
}) => PluginManifestCommandAliasRecord | undefined;
110+
resolveToolOwner?: (params: {
111+
toolName: string | undefined;
112+
config?: OpenClawConfig;
113+
registry?: PluginManifestCommandAliasRegistry;
114+
}) => PluginManifestToolOwnerRecord | undefined;
108115
},
109116
): string | null {
110117
const normalizedPluginId = normalizeLowercaseStringOrEmpty(pluginId);
@@ -171,6 +178,47 @@ export function resolveMissingPluginCommandMessage(
171178
return null;
172179
}
173180

181+
const toolOwner = options?.registry
182+
? resolveManifestToolOwnerInRegistry({
183+
toolName: normalizedPluginId,
184+
registry: options.registry,
185+
})
186+
: options?.resolveToolOwner?.({
187+
toolName: normalizedPluginId,
188+
config,
189+
...(options?.registry ? { registry: options.registry } : {}),
190+
});
191+
if (toolOwner) {
192+
// Apply plugins.allow / plugins.entries[X].enabled to the owning plugin so
193+
// a disabled/denied plugin's manifest-declared tool name does not get a
194+
// false "registered by" attribution. The runtime resolver
195+
// (resolveManifestToolOwner) already filters by control-plane availability,
196+
// but pure-registry callers and any future ones still need this guard.
197+
const ownerEnabled =
198+
config?.plugins?.entries?.[toolOwner.pluginId]?.enabled !== false &&
199+
(allow.length === 0 || allow.includes(toolOwner.pluginId));
200+
if (ownerEnabled) {
201+
// Per-account / per-tool runtime gates (e.g. Feishu's
202+
// channels.feishu.enabled / tools.<x> toggles) are not declarable as
203+
// manifest configSignals, so a positive manifest-availability signal
204+
// proves "could be loaded if config permits", not "currently registered".
205+
// Soften the wording when the runtime resolver could only prove
206+
// manifest-level ownership.
207+
if (toolOwner.availability === "manifest-only") {
208+
return (
209+
`"${normalizedPluginId}" may be provided by the "${toolOwner.pluginId}" plugin ` +
210+
`as an agent tool, not a CLI subcommand. ` +
211+
"Run `openclaw --help` to see available CLI subcommands."
212+
);
213+
}
214+
return (
215+
`"${normalizedPluginId}" is an agent tool registered by the "${toolOwner.pluginId}" plugin, ` +
216+
`not a CLI subcommand. Use it from an agent turn (model tool-use), not the CLI. ` +
217+
"Run `openclaw --help` to see available CLI subcommands."
218+
);
219+
}
220+
}
221+
174222
if (allow.length > 0 && !allow.includes(normalizedPluginId)) {
175223
if (parentPluginId && allow.includes(parentPluginId)) {
176224
return null;

src/cli/run-main.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ const memoryCoreCommandAliasRegistry: PluginManifestCommandAliasRegistry = {
3131
],
3232
};
3333

34+
const losslessClawToolRegistry: PluginManifestCommandAliasRegistry = {
35+
plugins: [
36+
{
37+
id: "lossless-claw",
38+
contracts: { tools: ["lcm_recent", "lcm_search"] },
39+
},
40+
],
41+
};
42+
3443
describe("isGatewayRunFastPathArgv", () => {
3544
it("matches only plain gateway foreground starts without root options or help", () => {
3645
expect(isGatewayRunFastPathArgv(["node", "openclaw", "gateway"])).toBe(true);
@@ -367,4 +376,101 @@ describe("resolveMissingPluginCommandMessage", () => {
367376
expect(message).toContain('"memory-wiki"');
368377
expect(message).toContain("plugins.allow");
369378
});
379+
380+
it("identifies an agent tool name and points the user at model tool-use", () => {
381+
const message = resolveMissingPluginCommandMessage(
382+
"lcm_recent",
383+
{
384+
plugins: {
385+
allow: ["lossless-claw"],
386+
},
387+
},
388+
{ registry: losslessClawToolRegistry },
389+
);
390+
expect(message).not.toBeNull();
391+
expect(message).toContain('"lcm_recent"');
392+
expect(message).toContain('"lossless-claw"');
393+
expect(message).toContain("agent tool");
394+
expect(message).not.toContain("plugins.allow");
395+
});
396+
397+
it("matches agent tool names case-insensitively", () => {
398+
const message = resolveMissingPluginCommandMessage("LCM_Recent", undefined, {
399+
registry: losslessClawToolRegistry,
400+
});
401+
expect(message).not.toBeNull();
402+
expect(message).toContain("agent tool");
403+
expect(message).toContain('"lossless-claw"');
404+
});
405+
406+
it("preserves the plugins.allow suggestion when the unknown name is not a registered tool", () => {
407+
const message = resolveMissingPluginCommandMessage(
408+
"totally-unknown",
409+
{
410+
plugins: {
411+
allow: ["quietchat"],
412+
},
413+
},
414+
{ registry: losslessClawToolRegistry },
415+
);
416+
expect(message).not.toBeNull();
417+
expect(message).toContain('`plugins.allow` excludes "totally-unknown"');
418+
});
419+
420+
it("does not attribute a tool to an owning plugin excluded by plugins.allow", () => {
421+
// The owning plugin is denied via plugins.allow, so the manifest-declared
422+
// tool is not actually registered at runtime. Fall through to the standard
423+
// plugins.allow message instead of falsely asserting "registered by".
424+
const message = resolveMissingPluginCommandMessage(
425+
"lcm_recent",
426+
{
427+
plugins: {
428+
allow: ["quietchat"],
429+
},
430+
},
431+
{ registry: losslessClawToolRegistry },
432+
);
433+
expect(message).not.toBeNull();
434+
expect(message).not.toContain("agent tool registered");
435+
expect(message).toContain('`plugins.allow` excludes "lcm_recent"');
436+
});
437+
438+
it("does not attribute a tool to an owning plugin disabled via plugins.entries", () => {
439+
const message = resolveMissingPluginCommandMessage(
440+
"lcm_recent",
441+
{
442+
plugins: {
443+
entries: {
444+
"lossless-claw": { enabled: false },
445+
},
446+
},
447+
},
448+
{ registry: losslessClawToolRegistry },
449+
);
450+
// entries.<id>.enabled = false on the OWNING plugin invalidates the
451+
// "registered by" claim. With no allow filter on the bare name the
452+
// diagnostic returns null (no actionable message); callers handle that
453+
// as "not a recognised plugin command".
454+
expect(message).toBeNull();
455+
});
456+
457+
it("uses softer 'may be provided by' wording for manifest-only availability", () => {
458+
// Some runtime gates (per-account enabled, per-tool toggles in the Feishu
459+
// family etc.) cannot be expressed as manifest configSignals, so the
460+
// runtime resolver reports availability: "manifest-only" when ownership is
461+
// only manifest-provable. The diagnostic must avoid asserting "registered
462+
// by" in that case.
463+
const manifestOnlyOwner = {
464+
toolName: "feishu_chat",
465+
pluginId: "feishu",
466+
availability: "manifest-only" as const,
467+
};
468+
const message = resolveMissingPluginCommandMessage("feishu_chat", undefined, {
469+
resolveToolOwner: () => manifestOnlyOwner,
470+
});
471+
expect(message).not.toBeNull();
472+
expect(message).toContain("may be provided by");
473+
expect(message).toContain('"feishu"');
474+
expect(message).not.toContain("registered by");
475+
});
370476
});

src/cli/run-main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,13 +665,14 @@ export async function runCli(argv: string[] = process.argv) {
665665
(command) => command.name() === primary || command.aliases().includes(primary),
666666
)
667667
) {
668-
const { resolveManifestCommandAliasOwner } =
668+
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
669669
await import("../plugins/manifest-command-aliases.runtime.js");
670670
const missingPluginCommandMessage = resolveMissingPluginCommandMessageFromPolicy(
671671
primary,
672672
config,
673673
{
674674
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
675+
resolveToolOwner: resolveManifestToolOwner,
675676
},
676677
);
677678
if (missingPluginCommandMessage) {

src/plugins/manifest-command-aliases.runtime.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
23
import {
34
resolveManifestCommandAliasOwnerInRegistry,
5+
resolveManifestToolOwnerInRegistry,
46
type PluginManifestCommandAliasRegistry,
57
type PluginManifestCommandAliasRecord,
8+
type PluginManifestToolOwnerRecord,
69
} from "./manifest-command-aliases.js";
7-
import { loadManifestMetadataRegistry } from "./manifest-contract-eligibility.js";
10+
import {
11+
isManifestPluginAvailableForControlPlane,
12+
loadManifestMetadataRegistry,
13+
loadManifestMetadataSnapshot,
14+
} from "./manifest-contract-eligibility.js";
15+
import { hasManifestToolAvailability } from "./manifest-tool-availability.js";
816

917
export function resolveManifestCommandAliasOwner(params: {
1018
command: string | undefined;
@@ -25,3 +33,83 @@ export function resolveManifestCommandAliasOwner(params: {
2533
registry,
2634
});
2735
}
36+
37+
/**
38+
* Resolve which plugin owns an agent-tool name, applying control-plane
39+
* availability filters so disabled/denied plugins are not falsely attributed.
40+
*
41+
* Behavior:
42+
* - Walks the full manifest snapshot (not the lighter-weight registry view) so
43+
* per-tool `configSignals`/`authSignals` are visible.
44+
* - Skips plugins that fail `isManifestPluginAvailableForControlPlane`
45+
* (`plugins.allow` / `plugins.deny` / `plugins.entries[id].enabled` /
46+
* installed-index).
47+
* - For matched tools, runs `hasManifestToolAvailability` to check the
48+
* tool's own configSignals (e.g. Feishu's `appId`/`appSecret` gate).
49+
* - Reports `availability: "loaded"` when both filters pass — caller can use
50+
* the strong "registered by" wording.
51+
* - Reports `availability: "manifest-only"` when the manifest declares
52+
* ownership but availability is not provable from manifest alone (e.g.
53+
* per-account `enabled` flags or per-tool toggles that are runtime-only).
54+
* Caller should soften the wording to "may be provided by".
55+
*
56+
* Falls back to the pure registry walk only when an explicit registry is
57+
* supplied (no snapshot to filter against).
58+
*/
59+
export function resolveManifestToolOwner(params: {
60+
toolName: string | undefined;
61+
config?: OpenClawConfig;
62+
workspaceDir?: string;
63+
env?: NodeJS.ProcessEnv;
64+
registry?: PluginManifestCommandAliasRegistry;
65+
}): PluginManifestToolOwnerRecord | undefined {
66+
if (params.registry) {
67+
return resolveManifestToolOwnerInRegistry({
68+
toolName: params.toolName,
69+
registry: params.registry,
70+
});
71+
}
72+
const normalizedToolName = normalizeOptionalLowercaseString(params.toolName);
73+
if (!normalizedToolName) {
74+
return undefined;
75+
}
76+
const snapshot = loadManifestMetadataSnapshot({
77+
config: params.config,
78+
workspaceDir: params.workspaceDir,
79+
env: params.env,
80+
});
81+
const env = params.env ?? process.env;
82+
for (const plugin of snapshot.plugins) {
83+
const tools = plugin.contracts?.tools;
84+
if (!tools || tools.length === 0) {
85+
continue;
86+
}
87+
const match = tools.find(
88+
(entry) => normalizeOptionalLowercaseString(entry) === normalizedToolName,
89+
);
90+
if (!match) {
91+
continue;
92+
}
93+
const pluginAvailable = isManifestPluginAvailableForControlPlane({
94+
snapshot,
95+
plugin,
96+
config: params.config,
97+
});
98+
if (!pluginAvailable) {
99+
// Plugin is denied/disabled/uninstalled; do not attribute this tool to it.
100+
continue;
101+
}
102+
const toolAvailable = hasManifestToolAvailability({
103+
plugin,
104+
toolNames: [match],
105+
config: params.config,
106+
env,
107+
});
108+
return {
109+
toolName: match,
110+
pluginId: plugin.id,
111+
availability: toolAvailable ? "loaded" : "manifest-only",
112+
};
113+
}
114+
return undefined;
115+
}

src/plugins/manifest-command-aliases.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import {
33
normalizeManifestCommandAliases,
44
resolveManifestCommandAliasOwnerInRegistry,
5+
resolveManifestToolOwnerInRegistry,
56
} from "./manifest-command-aliases.js";
67

78
describe("manifest command aliases", () => {
@@ -46,4 +47,31 @@ describe("manifest command aliases", () => {
4647
name: "legacy-memory",
4748
});
4849
});
50+
51+
it("resolves agent tool owners from contracts.tools", () => {
52+
const registry = {
53+
plugins: [
54+
{
55+
id: "lossless-claw",
56+
contracts: { tools: ["lcm_recent", "lcm_search"] },
57+
},
58+
{
59+
id: "other-plugin",
60+
contracts: { tools: ["unrelated_tool"] },
61+
},
62+
],
63+
};
64+
65+
expect(resolveManifestToolOwnerInRegistry({ toolName: "lcm_recent", registry })).toMatchObject({
66+
pluginId: "lossless-claw",
67+
toolName: "lcm_recent",
68+
});
69+
expect(resolveManifestToolOwnerInRegistry({ toolName: "LCM_Recent", registry })).toMatchObject({
70+
pluginId: "lossless-claw",
71+
});
72+
expect(
73+
resolveManifestToolOwnerInRegistry({ toolName: "missing_tool", registry }),
74+
).toBeUndefined();
75+
expect(resolveManifestToolOwnerInRegistry({ toolName: "", registry })).toBeUndefined();
76+
});
4977
});

0 commit comments

Comments
 (0)