diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index 834de3dec9fd..7586b02c8243 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -1,6 +1,10 @@ // Plugins CLI list tests cover plugin listing output and installed-state formatting. import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + ConfigFileSnapshot, + ConfigValidationIssue, + OpenClawConfig, +} from "../config/types.openclaw.js"; import { createPluginRecord } from "../plugins/status.test-fixtures.js"; import { withEnvAsync } from "../test-utils/env.js"; import { @@ -10,6 +14,7 @@ import { buildPluginSnapshotReport, inspectPluginRegistry, loadConfig, + loadPluginManifestRegistry, readConfigFileSnapshot, resetPluginsCliTestState, refreshPluginRegistry, @@ -27,6 +32,37 @@ const cleanDoctorMessage = "Plugin discovery, module loading, compatibility, and configuration checks passed. " + 'Run "openclaw health" to check the running Gateway, including runtime quarantines and fallbacks.'; +async function mockPluginDoctorValidationWarnings(warnings: ConfigValidationIssue[]) { + const config: OpenClawConfig = { + plugins: { + allow: ["imessage", "memory-core"], + entries: { google: { config: { apiKey: "test-google-key" } } }, + }, + }; + loadConfig.mockReturnValue(config); + const snapshot = (await readConfigFileSnapshot()) as ConfigFileSnapshot; + readConfigFileSnapshot.mockResolvedValueOnce({ ...snapshot, valid: true, warnings }); + loadPluginManifestRegistry.mockReturnValue({ + plugins: ["google", "imessage", "memory-core"].map((id) => ({ + id, + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin: "bundled", + rootDir: `/plugins/${id}`, + source: `/plugins/${id}`, + manifestPath: `/plugins/${id}/openclaw.plugin.json`, + })), + diagnostics: [], + }); + buildPluginDiagnosticsReport.mockReturnValue({ + plugins: [createPluginRecord({ id: "google", enabled: false, status: "disabled" })], + diagnostics: [], + }); +} + vi.mock("../skills/workshop/tool-policy-diagnostic.js", () => ({ detectSkillWorkshopToolPolicyDiagnostic: workshopMocks.detectToolPolicyDiagnostic, })); @@ -101,6 +137,110 @@ describe("plugins cli list", () => { expect(runtimeLogs).toContain(cleanDoctorMessage); }); + it.each([ + { format: "human", args: [] }, + { format: "JSON", args: ["--json"] }, + ])( + "reports validated disabled-plugin configuration warnings in $format output", + async ({ args }) => { + await mockPluginDoctorValidationWarnings([ + { + path: "plugins.entries.google", + message: "plugin disabled (not in allowlist) but config is present", + }, + ]); + + await runPluginsCommand(["plugins", "doctor", ...args]); + + const warning = + "- plugins.entries.google: plugin disabled (not in allowlist) but config is present"; + if (args.includes("--json")) { + const output = JSON.parse(runtimeLogs[0] ?? "null") as { + ok: boolean; + configurationWarnings: string[]; + }; + expect(output.ok).toBe(false); + expect(output.configurationWarnings).toEqual([warning]); + return; + } + expect(runtimeLogs.join("\n")).toContain(warning); + expect(runtimeLogs).not.toContain(cleanDoctorMessage); + }, + ); + + it("deduplicates plugin validation warnings while ignoring other config owners", async () => { + const googleWarning = { + path: "plugins.entries.google", + message: "plugin disabled (not in allowlist) but config is present", + }; + await mockPluginDoctorValidationWarnings([ + { path: "gateway.auth", message: "owned by gateway doctor" }, + { path: "plugins", message: "root plugin warning" }, + googleWarning, + googleWarning, + { path: "pluginsOther.entries.google", message: "not a plugin-owned path" }, + ]); + + await runPluginsCommand(["plugins", "doctor", "--json"]); + + const output = JSON.parse(runtimeLogs[0] ?? "null") as { + ok: boolean; + configurationWarnings: string[]; + }; + expect(output.ok).toBe(false); + expect(output.configurationWarnings).toEqual([ + "- plugins: root plugin warning", + "- plugins.entries.google: plugin disabled (not in allowlist) but config is present", + ]); + }); + + it.each([ + { format: "human", args: [] }, + { format: "JSON", args: ["--json"] }, + ])("ignores unrelated validation warnings in $format doctor output", async ({ args }) => { + await mockPluginDoctorValidationWarnings([ + { path: "gateway.auth", message: "owned by gateway doctor" }, + ]); + + await runPluginsCommand(["plugins", "doctor", ...args]); + + if (args.includes("--json")) { + expect(JSON.parse(runtimeLogs[0] ?? "null")).toMatchObject({ + ok: true, + configurationWarnings: [], + }); + return; + } + expect(runtimeLogs).toContain(cleanDoctorMessage); + }); + + it.each([ + { format: "human", args: [] }, + { format: "JSON", args: ["--json"] }, + ])("sanitizes plugin warning terminal controls in $format doctor output", async ({ args }) => { + await mockPluginDoctorValidationWarnings([ + { + path: "plugins.\nentries.google\u001b[31m", + message: "bad\r\n\tvalue\u001b[0m\u0007", + }, + ]); + + await runPluginsCommand(["plugins", "doctor", ...args]); + + const warning = "- plugins.\\nentries.google: bad\\r\\n\\tvalue"; + if (args.includes("--json")) { + expect(JSON.parse(runtimeLogs[0] ?? "null")).toMatchObject({ + ok: false, + configurationWarnings: [warning], + }); + return; + } + const output = runtimeLogs.join("\n"); + expect(output).toContain(warning); + expect(output).not.toContain("\u0007"); + expect(output).not.toContain("\u001b"); + }); + it("emits one sanitized JSON doctor report without human decoration", async () => { const homeDir = "/tmp/openclaw-plugin-doctor-home"; buildPluginDiagnosticsReport.mockReturnValue({ diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 1132d4162ed0..5c84a49804c6 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -12,6 +12,7 @@ import { readConfigFileSnapshot, replaceConfigFile, } from "../config/config.js"; +import { formatConfigIssueLines } from "../config/issue-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { emitDiagnosticsTimelineEvent } from "../infra/diagnostics-timeline.js"; import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js"; @@ -141,7 +142,6 @@ function formatDisabledRuntimePluginGuidance(params: { function collectConfiguredRuntimePluginWarnings(params: { cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; plugins: readonly { enabled?: boolean; id: string; status?: string }[]; }): string[] { const enabledPluginIds = new Set( @@ -369,9 +369,7 @@ export async function runPluginsDoctorCommand(opts: PluginDoctorOptions = {}): P } = await import("../commands/doctor/shared/stale-plugin-config.js"); const cfg = getRuntimeConfig(); const configSnapshot = await readConfigFileSnapshot().catch(() => null); - const sourceCfg = (configSnapshot?.sourceConfig ?? configSnapshot?.config ?? cfg) as - | OpenClawConfig - | undefined; + const sourceCfg = configSnapshot?.sourceConfig ?? configSnapshot?.config ?? cfg; const report = buildPluginDiagnosticsReport({ config: cfg, effectiveOnly: true }); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((entry) => !isConfigSelectedShadowDiagnostic(entry)); @@ -379,24 +377,25 @@ export async function runPluginsDoctorCommand(opts: PluginDoctorOptions = {}): P isErroredConfigSelectedShadowDiagnostic({ entry, plugins: report.plugins }), ); const compatibility = buildPluginCompatibilityNotices({ report }); - const stalePluginConfigHits = scanStalePluginConfig(sourceCfg ?? cfg, process.env); - const stalePluginConfigWarnings = collectStalePluginConfigWarnings({ - hits: stalePluginConfigHits, - doctorFixCommand: "openclaw doctor --fix", - autoRepairBlocked: isStalePluginAutoRepairBlocked(sourceCfg ?? cfg, process.env), - }); - const configuredRuntimePluginWarnings = collectConfiguredRuntimePluginWarnings({ - cfg: sourceCfg ?? cfg, - env: process.env, - plugins: report.plugins, - }); + const pluginConfigWarnings = new Set([ + ...formatConfigIssueLines( + (configSnapshot?.warnings ?? []).filter( + ({ path }) => path === "plugins" || path.startsWith("plugins."), + ), + ), + ...collectStalePluginConfigWarnings({ + hits: scanStalePluginConfig(sourceCfg, process.env), + doctorFixCommand: "openclaw doctor --fix", + autoRepairBlocked: isStalePluginAutoRepairBlocked(sourceCfg, process.env), + }), + ...collectConfiguredRuntimePluginWarnings({ cfg: sourceCfg, plugins: report.plugins }), + ]); const hasInstallTreeIssues = errors.length > 0 || diags.length > 0 || shadowed.length > 0 || compatibility.length > 0; - const pluginConfigWarnings = [...stalePluginConfigWarnings, ...configuredRuntimePluginWarnings]; if (opts.json) { defaultRuntime.writeJson({ - ok: !hasInstallTreeIssues && pluginConfigWarnings.length === 0, + ok: !hasInstallTreeIssues && pluginConfigWarnings.size === 0, pluginErrors: errors.map((entry) => ({ id: entry.id, ...(entry.failurePhase ? { failurePhase: entry.failurePhase } : {}), @@ -437,12 +436,12 @@ export async function runPluginsDoctorCommand(opts: PluginDoctorOptions = {}): P ...notice, message: shortenHomeInString(notice.message), })), - configurationWarnings: pluginConfigWarnings.map(shortenHomeInString), + configurationWarnings: Array.from(pluginConfigWarnings, shortenHomeInString), }); return; } - if (!hasInstallTreeIssues && pluginConfigWarnings.length === 0) { + if (!hasInstallTreeIssues && pluginConfigWarnings.size === 0) { defaultRuntime.log( "Plugin discovery, module loading, compatibility, and configuration checks passed. " + 'Run "openclaw health" to check the running Gateway, including runtime quarantines and fallbacks.', @@ -503,14 +502,14 @@ export async function runPluginsDoctorCommand(opts: PluginDoctorOptions = {}): P lines.push(`- ${formatPluginCompatibilityNotice(notice)} [${marker}]`); } } - if (pluginConfigWarnings.length > 0) { + if (pluginConfigWarnings.size > 0) { if (lines.length > 0) { lines.push(""); } lines.push(theme.warn("Plugin configuration:")); lines.push(...pluginConfigWarnings); } - if (!hasInstallTreeIssues && pluginConfigWarnings.length > 0) { + if (!hasInstallTreeIssues && pluginConfigWarnings.size > 0) { if (lines.length > 0) { lines.push(""); }