Plugins: surface compatibility notices

This commit is contained in:
Vincent Koc
2026-03-17 19:58:40 -07:00
parent 6b9b32a160
commit 27d4fdf3bb
16 changed files with 701 additions and 16 deletions

View File

@@ -62,6 +62,7 @@ describe("handleCommands /plugins", () => {
expect(showResult.reply?.text).toContain('"id": "superpowers"');
expect(showResult.reply?.text).toContain('"bundleFormat": "claude"');
expect(showResult.reply?.text).toContain('"shape":');
expect(showResult.reply?.text).toContain('"compatibilityWarnings": []');
const inspectAllParams = buildCommandTestParams(
"/plugins inspect all",
@@ -75,6 +76,7 @@ describe("handleCommands /plugins", () => {
const inspectAllResult = await handleCommands(inspectAllParams);
expect(inspectAllResult.reply?.text).toContain("```json");
expect(inspectAllResult.reply?.text).toContain('"plugin"');
expect(inspectAllResult.reply?.text).toContain('"compatibilityWarnings"');
expect(inspectAllResult.reply?.text).toContain('"superpowers"');
});
});

View File

@@ -45,6 +45,11 @@ function buildPluginInspectJson(params: {
}
return {
inspect,
compatibilityWarnings: inspect.compatibility.map((warning) => ({
code: warning.code,
severity: warning.severity,
message: `${warning.pluginId} ${warning.message}`,
})),
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
};
}
@@ -61,6 +66,11 @@ function buildAllPluginInspectJson(params: {
report: params.report,
}).map((inspect) => ({
inspect,
compatibilityWarnings: inspect.compatibility.map((warning) => ({
code: warning.code,
severity: warning.severity,
message: `${warning.pluginId} ${warning.message}`,
})),
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
}));
}

View File

@@ -22,6 +22,7 @@ import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
import {
buildAllPluginInspectReports,
buildPluginCompatibilityNotices,
buildPluginInspectReport,
buildPluginStatusReport,
} from "../plugins/status.js";
@@ -652,6 +653,12 @@ export function registerPluginsCli(program: Command) {
: theme.error("error"),
Shape: inspect.shape,
Capabilities: formatCapabilityKinds(inspect.capabilities),
Compatibility:
inspect.compatibility.length > 0
? inspect.compatibility
.map((entry) => (entry.severity === "warn" ? `warn:${entry.code}` : entry.code))
.join(", ")
: "none",
Hooks: formatHookSummary({
usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart,
typedHookCount: inspect.typedHooks.length,
@@ -667,6 +674,7 @@ export function registerPluginsCli(program: Command) {
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Shape", header: "Shape", minWidth: 18 },
{ key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true },
{ key: "Compatibility", header: "Compatibility", minWidth: 24, flex: true },
{ key: "Hooks", header: "Hooks", minWidth: 20, flex: true },
],
rows,
@@ -751,6 +759,12 @@ export function registerPluginsCli(program: Command) {
),
),
);
lines.push(
...formatInspectSection(
"Compatibility warnings",
inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`),
),
);
lines.push(
...formatInspectSection(
"Custom hooks",
@@ -1058,8 +1072,9 @@ export function registerPluginsCli(program: Command) {
const report = buildPluginStatusReport();
const errors = report.plugins.filter((p) => p.status === "error");
const diags = report.diagnostics.filter((d) => d.level === "error");
const compatibility = buildPluginCompatibilityNotices({ report });
if (errors.length === 0 && diags.length === 0) {
if (errors.length === 0 && diags.length === 0 && compatibility.length === 0) {
defaultRuntime.log("No plugin issues detected.");
return;
}
@@ -1081,6 +1096,16 @@ export function registerPluginsCli(program: Command) {
lines.push(`- ${target}${diag.message}`);
}
}
if (compatibility.length > 0) {
if (lines.length > 0) {
lines.push("");
}
lines.push(theme.warn("Compatibility:"));
for (const notice of compatibility) {
const marker = notice.severity === "warn" ? theme.warn("warn") : theme.muted("info");
lines.push(`- ${notice.pluginId} [${marker}]: ${notice.message}`);
}
}
const docs = formatDocsLink("/plugin", "docs.openclaw.ai/plugin");
lines.push("");
lines.push(`${theme.muted("Docs:")} ${docs}`);

View File

@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from "vitest";
import * as noteModule from "../terminal/note.js";
const resolveAgentWorkspaceDirMock = vi.fn();
const resolveDefaultAgentIdMock = vi.fn();
const buildWorkspaceSkillStatusMock = vi.fn();
const loadOpenClawPluginsMock = vi.fn();
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args),
resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args),
}));
vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: (...args: unknown[]) => buildWorkspaceSkillStatusMock(...args),
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
}));
describe("noteWorkspaceStatus", () => {
it("warns when plugins use legacy compatibility paths", async () => {
resolveDefaultAgentIdMock.mockReturnValue("default");
resolveAgentWorkspaceDirMock.mockReturnValue("/workspace");
buildWorkspaceSkillStatusMock.mockReturnValue({
skills: [],
});
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "legacy-plugin",
name: "Legacy Plugin",
source: "/tmp/legacy-plugin/index.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 1,
configSchema: false,
},
],
diagnostics: [],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [
{
pluginId: "legacy-plugin",
hookName: "before_agent_start",
handler: () => undefined,
source: "/tmp/legacy-plugin/index.ts",
},
],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
});
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
const { noteWorkspaceStatus } = await import("./doctor-workspace-status.js");
noteWorkspaceStatus({});
const compatibilityCalls = noteSpy.mock.calls.filter(
([, title]) => title === "Plugin compatibility",
);
expect(compatibilityCalls).toHaveLength(1);
expect(String(compatibilityCalls[0]?.[0])).toContain(
"legacy-plugin still relies on legacy before_agent_start",
);
expect(String(compatibilityCalls[0]?.[0])).toContain(
"legacy-plugin is hook-only; this remains supported for compatibility",
);
} finally {
noteSpy.mockRestore();
}
});
it("omits plugin compatibility note when no legacy compatibility paths are present", async () => {
resolveDefaultAgentIdMock.mockReturnValue("default");
resolveAgentWorkspaceDirMock.mockReturnValue("/workspace");
buildWorkspaceSkillStatusMock.mockReturnValue({
skills: [],
});
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "modern-plugin",
name: "Modern Plugin",
source: "/tmp/modern-plugin/index.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: ["modern"],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
});
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
const { noteWorkspaceStatus } = await import("./doctor-workspace-status.js");
noteWorkspaceStatus({});
expect(noteSpy.mock.calls.some(([, title]) => title === "Plugin compatibility")).toBe(false);
} finally {
noteSpy.mockRestore();
}
});
});

View File

@@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { buildPluginCompatibilityWarnings } from "../plugins/status.js";
import { note } from "../terminal/note.js";
import { detectLegacyWorkspaceDirs, formatLegacyWorkspaceWarning } from "./doctor-workspace.js";
@@ -54,6 +55,17 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
note(lines.join("\n"), "Plugins");
}
const compatibilityWarnings = buildPluginCompatibilityWarnings({
config: cfg,
workspaceDir,
report: {
workspaceDir,
...pluginRegistry,
},
});
if (compatibilityWarnings.length > 0) {
note(compatibilityWarnings.map((line) => `- ${line}`).join("\n"), "Plugin compatibility");
}
if (pluginRegistry.diagnostics.length > 0) {
const lines = pluginRegistry.diagnostics.map((diag) => {
const prefix = diag.level.toUpperCase();

View File

@@ -25,6 +25,7 @@ import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { readTailscaleStatusJson } from "../infra/tailscale.js";
import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js";
import { checkUpdateStatus, formatGitInstallLabel } from "../infra/update-check.js";
import { buildPluginCompatibilityNotices } from "../plugins/status.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { VERSION } from "../version.js";
@@ -238,6 +239,7 @@ export async function statusAllCommand(
}
})()
: null;
const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
const dashboard = controlUiEnabled
@@ -360,6 +362,7 @@ export async function statusAllCommand(
tailscale,
tailscaleHttpsUrl,
skillStatus,
pluginCompatibility,
channelsStatus,
channelIssues,
gatewayReachable,

View File

@@ -6,6 +6,7 @@ import {
type RestartSentinelPayload,
summarizeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import type { PluginCompatibilityNotice } from "../../plugins/status.js";
import { formatTimeAgo, redactSecrets } from "./format.js";
import { readFileTailLines, summarizeLogTail } from "./gateway.js";
@@ -59,6 +60,7 @@ export async function appendStatusAllDiagnosis(params: {
tailscale: TailscaleStatusLike;
tailscaleHttpsUrl: string | null;
skillStatus: SkillStatusLike | null;
pluginCompatibility: PluginCompatibilityNotice[];
channelsStatus: unknown;
channelIssues: ChannelIssueLike[];
gatewayReachable: boolean;
@@ -176,6 +178,18 @@ export async function appendStatusAllDiagnosis(params: {
);
}
emitCheck(
`Plugin compatibility (${params.pluginCompatibility.length || "none"})`,
params.pluginCompatibility.length === 0 ? "ok" : "warn",
);
for (const notice of params.pluginCompatibility.slice(0, 12)) {
const severity = notice.severity === "warn" ? "warn" : "info";
lines.push(` - ${notice.pluginId} [${severity}] ${notice.message}`);
}
if (params.pluginCompatibility.length > 12) {
lines.push(` ${muted(`… +${params.pluginCompatibility.length - 12} more`)}`);
}
params.progress.setLabel("Reading logs…");
const logPaths = (() => {
try {

View File

@@ -60,6 +60,7 @@ describe("buildStatusAllReportLines", () => {
},
tailscaleHttpsUrl: null,
skillStatus: null,
pluginCompatibility: [],
channelsStatus: null,
channelIssues: [],
gatewayReachable: false,

View File

@@ -137,6 +137,7 @@ export async function statusCommand(
secretDiagnostics,
memory,
memoryPlugin,
pluginCompatibility,
} = scan;
const usage = opts.usage
@@ -217,6 +218,10 @@ export async function statusCommand(
agents: agentStatus,
securityAudit,
secretDiagnostics,
pluginCompatibility: {
count: pluginCompatibility.length,
warnings: pluginCompatibility,
},
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
},
null,
@@ -416,6 +421,12 @@ export async function statusCommand(
const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, "");
const channelLabel = channelInfo.label;
const gitLabel = formatGitInstallLabel(update);
const pluginCompatibilityValue =
pluginCompatibility.length === 0
? ok("none")
: warn(
`${pluginCompatibility.length} notice${pluginCompatibility.length === 1 ? "" : "s"} · ${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size} plugin${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size === 1 ? "" : "s"}`,
);
const overviewRows = [
{ Item: "Dashboard", Value: dashboard },
@@ -443,6 +454,7 @@ export async function statusCommand(
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue },
{ Item: "Plugin compatibility", Value: pluginCompatibilityValue },
{ Item: "Probes", Value: probesValue },
{ Item: "Events", Value: eventsValue },
{ Item: "Heartbeat", Value: heartbeatValue },
@@ -467,6 +479,18 @@ export async function statusCommand(
}).trimEnd(),
);
if (pluginCompatibility.length > 0) {
runtime.log("");
runtime.log(theme.heading("Plugin compatibility"));
for (const notice of pluginCompatibility.slice(0, 8)) {
const label = notice.severity === "warn" ? theme.warn("WARN") : theme.muted("INFO");
runtime.log(` ${label} ${notice.pluginId} ${notice.message}`);
}
if (pluginCompatibility.length > 8) {
runtime.log(theme.muted(` … +${pluginCompatibility.length - 8} more`));
}
}
if (pairingRecovery) {
runtime.log("");
runtime.log(theme.warn("Gateway pairing approval required."));

View File

@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
probeGateway: vi.fn(),
resolveGatewayProbeAuthResolution: vi.fn(),
ensurePluginRegistryLoaded: vi.fn(),
buildPluginCompatibilityNotices: vi.fn(() => []),
}));
beforeEach(() => {
@@ -91,6 +92,10 @@ vi.mock("../cli/plugin-registry.js", () => ({
ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded,
}));
vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices,
}));
import { scanStatus } from "./status.scan.js";
describe("scanStatus", () => {

View File

@@ -8,6 +8,10 @@ import { readBestEffortConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import {
buildPluginCompatibilityNotices,
type PluginCompatibilityNotice,
} from "../plugins/status.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
@@ -107,6 +111,7 @@ export type StatusScanResult = {
summary: Awaited<ReturnType<typeof getStatusSummary>>;
memory: MemoryStatusSnapshot | null;
memoryPlugin: MemoryPluginStatus;
pluginCompatibility: PluginCompatibilityNotice[];
};
async function resolveMemoryStatusSnapshot(params: {
@@ -192,6 +197,7 @@ async function scanStatusJsonFast(opts: {
const memoryPlugin = resolveMemoryPluginStatus(cfg);
const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin });
const memory = await memoryPromise;
const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg });
return {
cfg,
@@ -216,6 +222,7 @@ async function scanStatusJsonFast(opts: {
summary,
memory,
memoryPlugin,
pluginCompatibility,
};
}
@@ -233,7 +240,7 @@ export async function scanStatus(
return await withProgress(
{
label: "Scanning status…",
total: 10,
total: 11,
enabled: true,
},
async (progress) => {
@@ -325,6 +332,10 @@ export async function scanStatus(
const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin });
progress.tick();
progress.setLabel("Checking plugins…");
const pluginCompatibility = buildPluginCompatibilityNotices({ config: cfg });
progress.tick();
progress.setLabel("Reading sessions…");
const summary = unwrapDeferredResult(await summaryPromise);
progress.tick();
@@ -355,6 +366,7 @@ export async function scanStatus(
summary,
memory,
memoryPlugin,
pluginCompatibility,
};
},
);

View File

@@ -205,6 +205,7 @@ const mocks = vi.hoisted(() => ({
},
],
}),
buildPluginCompatibilityNotices: vi.fn(() => []),
}));
vi.mock("../memory/manager.js", () => ({
@@ -385,6 +386,9 @@ vi.mock("../daemon/node-service.js", () => ({
vi.mock("../security/audit.js", () => ({
runSecurityAudit: mocks.runSecurityAudit,
}));
vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices,
}));
import { statusCommand } from "./status.js";
@@ -403,6 +407,15 @@ describe("statusCommand", () => {
});
it("prints JSON when requested", async () => {
mocks.buildPluginCompatibilityNotices.mockReturnValue([
{
pluginId: "legacy-plugin",
code: "legacy-before-agent-start",
severity: "warn",
message:
"still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
},
]);
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0]));
expect(payload.linkChannel).toBeUndefined();
@@ -424,6 +437,18 @@ describe("statusCommand", () => {
expect(payload.securityAudit.summary.warn).toBe(1);
expect(payload.gatewayService.label).toBe("LaunchAgent");
expect(payload.nodeService.label).toBe("LaunchAgent");
expect(payload.pluginCompatibility).toEqual({
count: 1,
warnings: [
{
pluginId: "legacy-plugin",
code: "legacy-before-agent-start",
severity: "warn",
message:
"still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
},
],
});
expect(mocks.runSecurityAudit).toHaveBeenCalledWith(
expect.objectContaining({
includeFilesystem: true,
@@ -452,6 +477,15 @@ describe("statusCommand", () => {
});
it("prints formatted lines otherwise", async () => {
mocks.buildPluginCompatibilityNotices.mockReturnValue([
{
pluginId: "legacy-plugin",
code: "legacy-before-agent-start",
severity: "warn",
message:
"still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
},
]);
const logs = await runStatusAndGetLogs();
for (const token of [
"OpenClaw status",
@@ -462,6 +496,7 @@ describe("statusCommand", () => {
"Dashboard",
"macos 14.0 (arm64)",
"Memory",
"Plugin compatibility",
"Channels",
"WhatsApp",
"bootstrap files",
@@ -476,6 +511,9 @@ describe("statusCommand", () => {
]) {
expect(logs.some((line) => line.includes(token))).toBe(true);
}
expect(
logs.some((line) => line.includes("legacy-plugin still relies on legacy before_agent_start")),
).toBe(true);
expect(
logs.some(
(line) =>

View File

@@ -5,6 +5,8 @@ const loadOpenClawPluginsMock = vi.fn();
let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport;
let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports;
let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices;
let buildPluginCompatibilityWarnings: typeof import("./status.js").buildPluginCompatibilityWarnings;
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfigMock(),
@@ -48,8 +50,13 @@ describe("buildPluginStatusReport", () => {
services: [],
commands: [],
});
({ buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport } =
await import("./status.js"));
({
buildAllPluginInspectReports,
buildPluginCompatibilityNotices,
buildPluginCompatibilityWarnings,
buildPluginInspectReport,
buildPluginStatusReport,
} = await import("./status.js"));
});
it("forwards an explicit env to plugin loading", () => {
@@ -148,6 +155,15 @@ describe("buildPluginStatusReport", () => {
"web-search",
]);
expect(inspect?.usesLegacyBeforeAgentStart).toBe(true);
expect(inspect?.compatibility).toEqual([
{
pluginId: "google",
code: "legacy-before-agent-start",
severity: "warn",
message:
"still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
},
]);
expect(inspect?.policy).toEqual({
allowPromptInjection: false,
allowModelOverride: true,
@@ -257,4 +273,219 @@ describe("buildPluginStatusReport", () => {
"web-search",
]);
});
it("builds compatibility warnings for legacy compatibility paths", () => {
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "lca",
name: "LCA",
description: "Legacy hook plugin",
source: "/tmp/lca/index.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 1,
configSchema: false,
},
],
diagnostics: [],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [
{
pluginId: "lca",
hookName: "before_agent_start",
handler: () => undefined,
source: "/tmp/lca/index.ts",
},
],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
expect(buildPluginCompatibilityWarnings()).toEqual([
"lca still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
"lca is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.",
]);
});
it("builds structured compatibility notices with deterministic ordering", () => {
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "hook-only",
name: "Hook Only",
description: "",
source: "/tmp/hook-only/index.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 1,
configSchema: false,
},
{
id: "legacy-only",
name: "Legacy Only",
description: "",
source: "/tmp/legacy-only/index.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: ["legacy-only"],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 1,
configSchema: false,
},
],
diagnostics: [],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [
{
pluginId: "hook-only",
events: ["message"],
entry: {
hook: {
name: "legacy",
handler: () => undefined,
},
},
},
],
typedHooks: [
{
pluginId: "legacy-only",
hookName: "before_agent_start",
handler: () => undefined,
source: "/tmp/legacy-only/index.ts",
},
],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
expect(buildPluginCompatibilityNotices()).toEqual([
{
pluginId: "hook-only",
code: "hook-only",
severity: "info",
message:
"is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.",
},
{
pluginId: "legacy-only",
code: "legacy-before-agent-start",
severity: "warn",
message:
"still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
},
]);
});
it("returns no compatibility warnings for modern capability plugins", () => {
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "modern",
name: "Modern",
description: "",
source: "/tmp/modern/index.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: ["modern"],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
expect(buildPluginCompatibilityNotices()).toEqual([]);
expect(buildPluginCompatibilityWarnings()).toEqual([]);
});
});

View File

@@ -26,6 +26,13 @@ export type PluginInspectShape =
| "hybrid-capability"
| "non-capability";
export type PluginCompatibilityNotice = {
pluginId: string;
code: "legacy-before-agent-start" | "hook-only";
severity: "warn" | "info";
message: string;
};
export type PluginInspectReport = {
workspaceDir?: string;
plugin: PluginRegistry["plugins"][number];
@@ -61,8 +68,34 @@ export type PluginInspectReport = {
hasAllowedModelsConfig: boolean;
};
usesLegacyBeforeAgentStart: boolean;
compatibility: PluginCompatibilityNotice[];
};
function buildCompatibilityNoticesForInspect(
inspect: Pick<PluginInspectReport, "plugin" | "shape" | "usesLegacyBeforeAgentStart">,
): PluginCompatibilityNotice[] {
const warnings: PluginCompatibilityNotice[] = [];
if (inspect.usesLegacyBeforeAgentStart) {
warnings.push({
pluginId: inspect.plugin.id,
code: "legacy-before-agent-start",
severity: "warn",
message:
"still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
});
}
if (inspect.shape === "hook-only") {
warnings.push({
pluginId: inspect.plugin.id,
code: "hook-only",
severity: "info",
message:
"is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.",
});
}
return warnings;
}
const log = createSubsystemLogger("plugins");
export function buildPluginStatusReport(params?: {
@@ -176,21 +209,30 @@ export function buildPluginInspectReport(params: {
const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id);
const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id];
const capabilityCount = capabilities.length;
const 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,
});
const usesLegacyBeforeAgentStart = typedHooks.some(
(entry) => entry.name === "before_agent_start",
);
const compatibility = buildCompatibilityNoticesForInspect({
plugin,
shape,
usesLegacyBeforeAgentStart,
});
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,
}),
shape,
capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid",
capabilityCount,
capabilities,
@@ -209,7 +251,8 @@ export function buildPluginInspectReport(params: {
allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])],
hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true,
},
usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"),
usesLegacyBeforeAgentStart,
compatibility,
};
}
@@ -238,3 +281,23 @@ export function buildAllPluginInspectReports(params?: {
)
.filter((entry): entry is PluginInspectReport => entry !== null);
}
export function buildPluginCompatibilityWarnings(params?: {
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
report?: PluginStatusReport;
}): string[] {
return buildAllPluginInspectReports(params).flatMap((inspect) =>
inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`),
);
}
export function buildPluginCompatibilityNotices(params?: {
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
report?: PluginStatusReport;
}): PluginCompatibilityNotice[] {
return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility);
}

View File

@@ -88,6 +88,7 @@ const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: tru
const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {}));
const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {}));
const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const buildPluginCompatibilityNotices = vi.hoisted(() => vi.fn(() => []));
vi.mock("../commands/onboard-channels.js", () => ({
setupChannels,
@@ -172,6 +173,10 @@ vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt,
}));
vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilityNotices,
}));
vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins,
}));
@@ -398,6 +403,62 @@ describe("runSetupWizard", () => {
}
});
it("shows plugin compatibility notices for an existing valid config", async () => {
buildPluginCompatibilityNotices.mockReturnValue([
{
pluginId: "legacy-plugin",
code: "legacy-before-agent-start",
severity: "warn",
message:
"still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.",
},
]);
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
valid: true,
config: {
gateway: {},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const note: WizardPrompter["note"] = vi.fn(async () => {});
const select = vi.fn(async (opts: WizardSelectParams<unknown>) => {
if (opts.message === "Config handling") {
return "keep";
}
return "quickstart";
}) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ note, select });
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipProviders: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true);
expect(calls.some((call) => String(call?.[0] ?? "").includes("legacy-plugin"))).toBe(true);
});
it("resolves gateway.auth.password SecretRef for local setup probe", async () => {
const previous = process.env.OPENCLAW_GATEWAY_PASSWORD;
process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; // pragma: allowlist secret

View File

@@ -13,6 +13,7 @@ import {
writeConfigFile,
} from "../config/config.js";
import { normalizeSecretInputString } from "../config/types.secrets.js";
import { buildPluginCompatibilityNotices } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
@@ -102,6 +103,27 @@ export async function runSetupWizard(
return;
}
const compatibilityNotices = snapshot.valid
? buildPluginCompatibilityNotices({ config: baseConfig })
: [];
if (compatibilityNotices.length > 0) {
await prompter.note(
[
`Detected ${compatibilityNotices.length} plugin compatibility notice${compatibilityNotices.length === 1 ? "" : "s"} in the current config.`,
...compatibilityNotices
.slice(0, 4)
.map((notice) => `- ${notice.pluginId}: ${notice.message}`),
...(compatibilityNotices.length > 4
? [`- ... +${compatibilityNotices.length - 4} more`]
: []),
"",
`Review: ${formatCliCommand("openclaw doctor")}`,
`Inspect: ${formatCliCommand("openclaw plugins inspect --all")}`,
].join("\n"),
"Plugin compatibility",
);
}
const quickstartHint = `Configure details later via ${formatCliCommand("openclaw configure")}.`;
const manualHint = "Configure port, network, Tailscale, and auth options.";
const explicitFlowRaw = opts.flow?.trim();