mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 10:40:42 +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
@@ -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 <name>` 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user