Plugins: add inspect command and capability report

This commit is contained in:
Vincent Koc
2026-03-17 10:15:22 -07:00
parent e4825a0f93
commit 3983928958
6 changed files with 469 additions and 81 deletions

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.fn();
const loadOpenClawPluginsMock = vi.fn();
let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport;
let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfigMock(),
@@ -32,14 +33,21 @@ describe("buildPluginStatusReport", () => {
diagnostics: [],
channels: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [],
channelSetups: [],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
({ buildPluginStatusReport } = await import("./status.js"));
({ buildPluginInspectReport, buildPluginStatusReport } = await import("./status.js"));
});
it("forwards an explicit env to plugin loading", () => {
@@ -59,4 +67,93 @@ describe("buildPluginStatusReport", () => {
}),
);
});
it("builds an inspect report with capability shape and policy", () => {
loadConfigMock.mockReturnValue({
plugins: {
entries: {
google: {
hooks: { allowPromptInjection: false },
subagent: {
allowModelOverride: true,
allowedModels: ["openai/gpt-5.4"],
},
},
},
},
});
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "google",
name: "Google",
description: "Google provider plugin",
source: "/tmp/google/index.ts",
origin: "bundled",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: ["google"],
speechProviderIds: [],
mediaUnderstandingProviderIds: ["google"],
imageGenerationProviderIds: ["google"],
webSearchProviderIds: ["google"],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [{ level: "warn", pluginId: "google", message: "watch this seam" }],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [
{
pluginId: "google",
hookName: "before_agent_start",
handler: () => undefined,
source: "/tmp/google/index.ts",
},
],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
const inspect = buildPluginInspectReport({ id: "google" });
expect(inspect).not.toBeNull();
expect(inspect?.shape).toBe("hybrid-capability");
expect(inspect?.capabilityMode).toBe("hybrid");
expect(inspect?.capabilities.map((entry) => entry.kind)).toEqual([
"text-inference",
"media-understanding",
"image-generation",
"web-search",
]);
expect(inspect?.usesLegacyBeforeAgentStart).toBe(true);
expect(inspect?.policy).toEqual({
allowPromptInjection: false,
allowModelOverride: true,
allowedModels: ["openai/gpt-5.4"],
hasAllowedModelsConfig: true,
});
expect(inspect?.diagnostics).toEqual([
{ level: "warn", pluginId: "google", message: "watch this seam" },
]);
});
});

View File

@@ -2,14 +2,67 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizePluginsConfig } from "./config-state.js";
import { loadOpenClawPlugins } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import type { PluginRegistry } from "./registry.js";
import type { PluginDiagnostic, PluginHookName } from "./types.js";
export type PluginStatusReport = PluginRegistry & {
workspaceDir?: string;
};
export type PluginCapabilityKind =
| "text-inference"
| "speech"
| "media-understanding"
| "image-generation"
| "web-search"
| "channel";
export type PluginInspectShape =
| "hook-only"
| "plain-capability"
| "hybrid-capability"
| "non-capability";
export type PluginInspectReport = {
workspaceDir?: string;
plugin: PluginRegistry["plugins"][number];
shape: PluginInspectShape;
capabilityMode: "none" | "plain" | "hybrid";
capabilityCount: number;
capabilities: Array<{
kind: PluginCapabilityKind;
ids: string[];
}>;
typedHooks: Array<{
name: PluginHookName;
priority?: number;
}>;
customHooks: Array<{
name: string;
events: string[];
}>;
tools: Array<{
names: string[];
optional: boolean;
}>;
commands: string[];
cliCommands: string[];
services: string[];
gatewayMethods: string[];
httpRouteCount: number;
diagnostics: PluginDiagnostic[];
policy: {
allowPromptInjection?: boolean;
allowModelOverride?: boolean;
allowedModels: string[];
hasAllowedModelsConfig: boolean;
};
usesLegacyBeforeAgentStart: boolean;
};
const log = createSubsystemLogger("plugins");
export function buildPluginStatusReport(params?: {
@@ -36,3 +89,126 @@ export function buildPluginStatusReport(params?: {
...registry,
};
}
function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) {
return [
{ kind: "text-inference" as const, ids: plugin.providerIds },
{ kind: "speech" as const, ids: plugin.speechProviderIds },
{ kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds },
{ kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds },
{ kind: "web-search" as const, ids: plugin.webSearchProviderIds },
{ kind: "channel" as const, ids: plugin.channelIds },
].filter((entry) => entry.ids.length > 0);
}
function deriveInspectShape(params: {
capabilityCount: number;
typedHookCount: number;
customHookCount: number;
toolCount: number;
commandCount: number;
cliCount: number;
serviceCount: number;
gatewayMethodCount: number;
httpRouteCount: number;
}): PluginInspectShape {
if (params.capabilityCount > 1) {
return "hybrid-capability";
}
if (params.capabilityCount === 1) {
return "plain-capability";
}
const hasOnlyHooks =
params.typedHookCount + params.customHookCount > 0 &&
params.toolCount === 0 &&
params.commandCount === 0 &&
params.cliCount === 0 &&
params.serviceCount === 0 &&
params.gatewayMethodCount === 0 &&
params.httpRouteCount === 0;
if (hasOnlyHooks) {
return "hook-only";
}
return "non-capability";
}
export function buildPluginInspectReport(params: {
id: string;
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
report?: PluginStatusReport;
}): PluginInspectReport | null {
const config = params.config ?? loadConfig();
const report =
params.report ??
buildPluginStatusReport({
config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const plugin = report.plugins.find((entry) => entry.id === params.id || entry.name === params.id);
if (!plugin) {
return null;
}
const capabilities = buildCapabilityEntries(plugin);
const typedHooks = report.typedHooks
.filter((entry) => entry.pluginId === plugin.id)
.map((entry) => ({
name: entry.hookName,
priority: entry.priority,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const customHooks = report.hooks
.filter((entry) => entry.pluginId === plugin.id)
.map((entry) => ({
name: entry.entry.hook.name,
events: [...entry.events].sort(),
}))
.sort((a, b) => a.name.localeCompare(b.name));
const tools = report.tools
.filter((entry) => entry.pluginId === plugin.id)
.map((entry) => ({
names: [...entry.names],
optional: entry.optional,
}));
const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id);
const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id];
const capabilityCount = capabilities.length;
return {
workspaceDir: report.workspaceDir,
plugin,
shape: deriveInspectShape({
capabilityCount,
typedHookCount: typedHooks.length,
customHookCount: customHooks.length,
toolCount: tools.length,
commandCount: plugin.commands.length,
cliCount: plugin.cliCommands.length,
serviceCount: plugin.services.length,
gatewayMethodCount: plugin.gatewayMethods.length,
httpRouteCount: plugin.httpRoutes,
}),
capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid",
capabilityCount,
capabilities,
typedHooks,
customHooks,
tools,
commands: [...plugin.commands],
cliCommands: [...plugin.cliCommands],
services: [...plugin.services],
gatewayMethods: [...plugin.gatewayMethods],
httpRouteCount: plugin.httpRoutes,
diagnostics,
policy: {
allowPromptInjection: policyEntry?.hooks?.allowPromptInjection,
allowModelOverride: policyEntry?.subagent?.allowModelOverride,
allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])],
hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true,
},
usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"),
};
}