From 0c50957dbb96843f633c8dbe169e3108a2c7eebc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 9 May 2026 03:11:44 -0400 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + src/cli/run-main-policy.ts | 48 ++++++++ src/cli/run-main.exit.test.ts | 25 +++++ src/cli/run-main.test.ts | 106 ++++++++++++++++++ src/cli/run-main.ts | 22 +++- src/infra/update-global.test.ts | 14 +-- src/infra/update-runner.test.ts | 12 +- .../manifest-command-aliases.runtime.ts | 90 ++++++++++++++- src/plugins/manifest-command-aliases.test.ts | 28 +++++ src/plugins/manifest-command-aliases.ts | 41 +++++++ 10 files changed, 369 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 153051f038b..0b7c50e50a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,6 +146,7 @@ Docs: https://docs.openclaw.ai - Dependencies: pin the transitive `fast-uri` production dependency to `3.1.2` so the production dependency audit no longer resolves the vulnerable `<=3.1.1` range. Thanks @shakkernerd. - Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD. - Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD. +- CLI/router: when `openclaw ` does not match a CLI subcommand, check plugin tool manifests first so names like `lcm_recent` get an agent-tool diagnostic instead of the misleading suggestion to add the tool name to `plugins.allow`. Fixes #77214. Thanks @100yenadmin. - 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. - Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup. - Plugins/Nix: allow externally configured plugin roots under `/nix/store` to load in `OPENCLAW_NIX_MODE=1` while keeping normal external plugin hardlink rejection unchanged. Thanks @joshp123. diff --git a/src/cli/run-main-policy.ts b/src/cli/run-main-policy.ts index 039ee9c1461..bf3b17e606e 100644 --- a/src/cli/run-main-policy.ts +++ b/src/cli/run-main-policy.ts @@ -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. 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; diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index f9db711c67f..a7889f591c4 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -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); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 5165acd0722..98ce87916d4 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -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..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"); + }); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 3b457229ee2..eedbe448e24 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -338,6 +338,21 @@ async function resolveUnownedCliPrimary(params: { return primary; } +async function resolveUnownedCliPrimaryMessage(params: { + primary: string; + config: OpenClawConfig; +}): Promise { + 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, 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) { diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 4f52884fc49..557060a3f03 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -33,7 +33,7 @@ import { type CommandRunner, } from "./update-global.js"; -const MATRIX_HELPER_API = bundledDistPluginFile("matrix", "helper-api.js"); +const TELEGRAM_RUNTIME_API = bundledDistPluginFile("telegram", "runtime-api.js"); async function writeGlobalPackageJson(packageRoot: string, version = "1.0.0") { await fs.writeFile( path.join(packageRoot, "package.json"), @@ -687,9 +687,9 @@ describe("update global helpers", () => { await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toStrictEqual([]); - await fs.rm(path.join(packageRoot, MATRIX_HELPER_API)); + await fs.rm(path.join(packageRoot, TELEGRAM_RUNTIME_API)); await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( - `missing packaged dist file ${MATRIX_HELPER_API}`, + `missing packaged dist file ${TELEGRAM_RUNTIME_API}`, ); await fs.writeFile( @@ -796,10 +796,10 @@ describe("update global helpers", () => { it("verifies legacy sidecars for installed bundled plugins without inventory", async () => { await withTempDir({ prefix: "openclaw-update-global-legacy-plugin-" }, async (packageRoot) => { await writeGlobalPackageJson(packageRoot); - await writeBundledPluginPackageJson(packageRoot, "matrix", "@openclaw/matrix"); + await writeBundledPluginPackageJson(packageRoot, "telegram", "@openclaw/telegram"); await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( - `missing bundled runtime sidecar ${MATRIX_HELPER_API}`, + `missing bundled runtime sidecar ${TELEGRAM_RUNTIME_API}`, ); }); }); @@ -809,11 +809,11 @@ describe("update global helpers", () => { { prefix: "openclaw-update-global-critical-sidecars-" }, async (packageRoot) => { await writeGlobalPackageJson(packageRoot, "2026.4.15"); - await writeBundledPluginPackageJson(packageRoot, "matrix", "@openclaw/matrix"); + await writeBundledPluginPackageJson(packageRoot, "telegram", "@openclaw/telegram"); await writePackageDistInventory(packageRoot); await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( - `missing bundled runtime sidecar ${MATRIX_HELPER_API}`, + `missing bundled runtime sidecar ${TELEGRAM_RUNTIME_API}`, ); }, ); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index fbca32e2fc9..5b718b9317e 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -12,7 +12,7 @@ import { runGatewayUpdate } from "./update-runner.js"; type CommandResponse = { stdout?: string; stderr?: string; code?: number | null }; type CommandResult = { stdout: string; stderr: string; code: number | null }; -const MATRIX_HELPER_API = bundledDistPluginFile("matrix", "helper-api.js"); +const TELEGRAM_RUNTIME_API = bundledDistPluginFile("telegram", "runtime-api.js"); const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-update-" }); function toCommandResult(response?: CommandResponse): CommandResult { @@ -1776,10 +1776,10 @@ describe("runGatewayUpdate", () => { ); await writeBundledRuntimeSidecars(pkgRoot); const inventory = await writePackageDistInventory(pkgRoot); - expect(inventory).toContain(MATRIX_HELPER_API); - const matrixHelperApiPath = path.join(pkgRoot, MATRIX_HELPER_API); - await expect(pathExists(matrixHelperApiPath)).resolves.toBe(true); - await fs.rm(matrixHelperApiPath); + expect(inventory).toContain(TELEGRAM_RUNTIME_API); + const telegramRuntimeApiPath = path.join(pkgRoot, TELEGRAM_RUNTIME_API); + await expect(pathExists(telegramRuntimeApiPath)).resolves.toBe(true); + await fs.rm(telegramRuntimeApiPath); }, }); @@ -1788,7 +1788,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("error"); expect(result.reason).toBe("global-install-failed"); expect(result.steps.at(-1)?.stderrTail).toContain( - `missing packaged dist file ${MATRIX_HELPER_API}`, + `missing packaged dist file ${TELEGRAM_RUNTIME_API}`, ); }); diff --git a/src/plugins/manifest-command-aliases.runtime.ts b/src/plugins/manifest-command-aliases.runtime.ts index fdacec73f3f..c68b2c44ebb 100644 --- a/src/plugins/manifest-command-aliases.runtime.ts +++ b/src/plugins/manifest-command-aliases.runtime.ts @@ -1,10 +1,18 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { resolveManifestCommandAliasOwnerInRegistry, + resolveManifestToolOwnerInRegistry, type PluginManifestCommandAliasRegistry, type PluginManifestCommandAliasRecord, + type PluginManifestToolOwnerRecord, } from "./manifest-command-aliases.js"; -import { loadManifestMetadataRegistry } from "./manifest-contract-eligibility.js"; +import { + isManifestPluginAvailableForControlPlane, + loadManifestMetadataRegistry, + loadManifestMetadataSnapshot, +} from "./manifest-contract-eligibility.js"; +import { hasManifestToolAvailability } from "./manifest-tool-availability.js"; export function resolveManifestCommandAliasOwner(params: { command: string | undefined; @@ -25,3 +33,83 @@ export function resolveManifestCommandAliasOwner(params: { registry, }); } + +/** + * Resolve which plugin owns an agent-tool name, applying control-plane + * availability filters so disabled/denied plugins are not falsely attributed. + * + * Behavior: + * - Walks the full manifest snapshot (not the lighter-weight registry view) so + * per-tool `configSignals`/`authSignals` are visible. + * - Skips plugins that fail `isManifestPluginAvailableForControlPlane` + * (`plugins.allow` / `plugins.deny` / `plugins.entries[id].enabled` / + * installed-index). + * - For matched tools, runs `hasManifestToolAvailability` to check the + * tool's own configSignals (e.g. Feishu's `appId`/`appSecret` gate). + * - Reports `availability: "loaded"` when both filters pass, enough for a + * direct "available from this plugin" diagnostic. + * - Reports `availability: "manifest-only"` when the manifest declares + * ownership but availability is not provable from manifest alone (e.g. + * per-account `enabled` flags or per-tool toggles that are runtime-only). + * Caller should soften the wording to "may be provided by". + * + * Falls back to the pure registry walk only when an explicit registry is + * supplied (no snapshot to filter against). + */ +export function resolveManifestToolOwner(params: { + toolName: string | undefined; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + registry?: PluginManifestCommandAliasRegistry; +}): PluginManifestToolOwnerRecord | undefined { + if (params.registry) { + return resolveManifestToolOwnerInRegistry({ + toolName: params.toolName, + registry: params.registry, + }); + } + const normalizedToolName = normalizeOptionalLowercaseString(params.toolName); + if (!normalizedToolName) { + return undefined; + } + const snapshot = loadManifestMetadataSnapshot({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const env = params.env ?? process.env; + for (const plugin of snapshot.plugins) { + const tools = plugin.contracts?.tools; + if (!tools || tools.length === 0) { + continue; + } + const match = tools.find( + (entry) => normalizeOptionalLowercaseString(entry) === normalizedToolName, + ); + if (!match) { + continue; + } + const pluginAvailable = isManifestPluginAvailableForControlPlane({ + snapshot, + plugin, + config: params.config, + }); + if (!pluginAvailable) { + // Plugin is denied/disabled/uninstalled; do not attribute this tool to it. + continue; + } + const toolAvailable = hasManifestToolAvailability({ + plugin, + toolNames: [match], + config: params.config, + env, + }); + return { + toolName: match, + pluginId: plugin.id, + availability: toolAvailable ? "loaded" : "manifest-only", + }; + } + return undefined; +} diff --git a/src/plugins/manifest-command-aliases.test.ts b/src/plugins/manifest-command-aliases.test.ts index 4c4ea3c23d3..65b8b56d4ec 100644 --- a/src/plugins/manifest-command-aliases.test.ts +++ b/src/plugins/manifest-command-aliases.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { normalizeManifestCommandAliases, resolveManifestCommandAliasOwnerInRegistry, + resolveManifestToolOwnerInRegistry, } from "./manifest-command-aliases.js"; describe("manifest command aliases", () => { @@ -46,4 +47,31 @@ describe("manifest command aliases", () => { name: "legacy-memory", }); }); + + it("resolves agent tool owners from contracts.tools", () => { + const registry = { + plugins: [ + { + id: "lossless-claw", + contracts: { tools: ["lcm_recent", "lcm_search"] }, + }, + { + id: "other-plugin", + contracts: { tools: ["unrelated_tool"] }, + }, + ], + }; + + expect(resolveManifestToolOwnerInRegistry({ toolName: "lcm_recent", registry })).toMatchObject({ + pluginId: "lossless-claw", + toolName: "lcm_recent", + }); + expect(resolveManifestToolOwnerInRegistry({ toolName: "LCM_Recent", registry })).toMatchObject({ + pluginId: "lossless-claw", + }); + expect( + resolveManifestToolOwnerInRegistry({ toolName: "missing_tool", registry }), + ).toBeUndefined(); + expect(resolveManifestToolOwnerInRegistry({ toolName: "", registry })).toBeUndefined(); + }); }); diff --git a/src/plugins/manifest-command-aliases.ts b/src/plugins/manifest-command-aliases.ts index 8ba3c51b0f8..b91a4770f97 100644 --- a/src/plugins/manifest-command-aliases.ts +++ b/src/plugins/manifest-command-aliases.ts @@ -20,11 +20,29 @@ export type PluginManifestCommandAliasRecord = PluginManifestCommandAlias & { enabledByDefault?: boolean; }; +export type PluginManifestToolOwnerRecord = { + toolName: string; + pluginId: string; + /** + * "loaded" — the owning plugin passes control-plane availability filters and + * the tool itself passes manifest-tool-availability checks (configSignals/ + * authSignals). The diagnostic can say the tool is available from this plugin. + * + * "manifest-only" — the manifest claims ownership but availability checks + * either failed (plugin denied/disabled, missing required config) or were + * not performed (pure registry lookup with no plugin metadata snapshot). + * Emit a softer "may be provided by" message in that case so the diagnostic + * does not over-assert about plugins that the runtime never registered. + */ + availability?: "loaded" | "manifest-only"; +}; + export type PluginManifestCommandAliasRegistry = { plugins: readonly { id: string; enabledByDefault?: boolean; commandAliases?: readonly PluginManifestCommandAlias[]; + contracts?: { tools?: readonly string[] }; }[]; }; @@ -62,6 +80,29 @@ export function normalizeManifestCommandAliases( return normalized.length > 0 ? normalized : undefined; } +export function resolveManifestToolOwnerInRegistry(params: { + toolName: string | undefined; + registry: PluginManifestCommandAliasRegistry; +}): PluginManifestToolOwnerRecord | undefined { + const normalizedToolName = normalizeOptionalLowercaseString(params.toolName); + if (!normalizedToolName) { + return undefined; + } + for (const plugin of params.registry.plugins) { + const tools = plugin.contracts?.tools; + if (!tools || tools.length === 0) { + continue; + } + const match = tools.find( + (entry) => normalizeOptionalLowercaseString(entry) === normalizedToolName, + ); + if (match) { + return { toolName: match, pluginId: plugin.id }; + } + } + return undefined; +} + export function resolveManifestCommandAliasOwnerInRegistry(params: { command: string | undefined; registry: PluginManifestCommandAliasRegistry;