From 5a7b861ea291c31c5c010e3b383a458c19c97ac9 Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Sun, 17 May 2026 23:11:15 -0700 Subject: [PATCH] fix(config): keep unrelated plugin diagnostics nonfatal (#83438) * fix(config): keep unrelated plugin diagnostics nonfatal * docs(changelog): mention config plugin validation fix --- CHANGELOG.md | 1 + src/config/config.plugin-validation.test.ts | 98 ++++++++++++++++++++- src/config/validation.ts | 89 ++++++++++++++++++- 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93625bbc5ee4..161bfe2ad95f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Providers/Xiaomi: replay MiMo Anthropic-compatible `reasoning_content` as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for `mimo-v2-flash`. Fixes #83407. Thanks @Xgenious7. - Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi. - Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings. +- CLI/config: keep broken discovered plugins that are not referenced by active config from failing `openclaw config validate`, while preserving fatal errors for explicitly configured plugin entries. - GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev. - Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes. - QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage. diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 03ae0fdb5b28..94e0f82ff835 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -258,7 +258,26 @@ describe("config plugin validation", () => { expect(res.ok).toBe(false); if (!res.ok) { expectPathMessage(res.issues, "plugins.slots.memory", "plugin not found: missing-slot"); - expect(res.warnings).toEqual([ + expect(res.warnings).toEqual( + expect.arrayContaining([ + { + path: "plugins.entries.missing-plugin", + message: + "plugin not found: missing-plugin (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.allow", + message: + "plugin not found: missing-allow (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.deny", + message: + "plugin not found: missing-deny (stale config entry ignored; remove it from plugins config)", + }, + ]), + ); + expect(res.warnings.filter((warning) => warning.path.startsWith("plugins."))).toEqual([ { path: "plugins.entries.missing-plugin", message: @@ -560,6 +579,83 @@ describe("config plugin validation", () => { ).toBe(false); }); + it("warns for broken discovered plugins that are not referenced by config", () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "pi" }] }, + plugins: { + allow: ["telegram"], + }, + }, + { + env: suiteEnv(), + pluginMetadataSnapshot: { + manifestRegistry: { + plugins: [], + diagnostics: [ + { + level: "error", + pluginId: "broken-local", + source: path.join(suiteHome, "extensions", "broken-local", "openclaw.plugin.json"), + message: "plugin manifest entry does not exist: dist/index.js", + }, + ], + }, + }, + }, + ); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expectPathMessage( + res.warnings, + "plugins", + "plugin broken-local: plugin manifest entry does not exist: dist/index.js", + ); + expectNoPath(res.warnings, "plugins.entries.broken-local"); + }); + + it("keeps broken discovered plugins fatal when config references them", () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "pi" }] }, + plugins: { + entries: { + "broken-local": { enabled: true }, + }, + }, + }, + { + env: suiteEnv(), + pluginMetadataSnapshot: { + manifestRegistry: { + plugins: [], + diagnostics: [ + { + level: "error", + pluginId: "broken-local", + source: path.join(suiteHome, "extensions", "broken-local", "openclaw.plugin.json"), + message: "plugin manifest entry does not exist: dist/index.js", + }, + ], + }, + }, + }, + ); + + expect(res.ok).toBe(false); + if (res.ok) { + return; + } + expectPathMessage( + res.issues, + "plugins.entries.broken-local", + "plugin broken-local: plugin manifest entry does not exist: dist/index.js", + ); + }); + it("does not source-match blocked diagnostics that already name a different plugin id", () => { const aliasDir = path.join(suiteHome, "alias-dir"); const res = validateConfigObjectWithPlugins( diff --git a/src/config/validation.ts b/src/config/validation.ts index dcbbe4e26a31..0930c283b4aa 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -50,6 +50,12 @@ const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:"; type UnknownIssueRecord = Record; type ConfigPathSegment = string | number; +type ExplicitPluginReferences = { + entries: Set; + allow: Set; + deny: Set; + slots: Map; +}; type AllowedValuesCollection = { values: unknown[]; incomplete: boolean; @@ -567,6 +573,81 @@ function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue { }; } +function collectExplicitPluginReferences(raw: unknown): ExplicitPluginReferences { + const references: ExplicitPluginReferences = { + entries: new Set(), + allow: new Set(), + deny: new Set(), + slots: new Map(), + }; + if (!isRecord(raw) || !isRecord(raw.plugins)) { + return references; + } + const { plugins } = raw; + if (isRecord(plugins.entries)) { + for (const pluginId of Object.keys(plugins.entries)) { + const normalized = normalizePluginId(pluginId); + if (normalized) { + references.entries.add(normalized); + } + } + } + for (const [key, target] of [ + ["allow", references.allow], + ["deny", references.deny], + ] as const) { + const value = plugins[key]; + if (!Array.isArray(value)) { + continue; + } + for (const entry of value) { + if (typeof entry !== "string") { + continue; + } + const normalized = normalizePluginId(entry); + if (normalized) { + target.add(normalized); + } + } + } + if (isRecord(plugins.slots)) { + for (const [slotId, pluginId] of Object.entries(plugins.slots)) { + if (typeof pluginId !== "string") { + continue; + } + const normalized = normalizePluginId(pluginId); + if (normalized && normalized !== "none") { + references.slots.set(normalized, slotId); + } + } + } + return references; +} + +function resolveExplicitPluginReferencePath( + references: ExplicitPluginReferences, + pluginId: string, +): string | undefined { + const normalized = normalizePluginId(pluginId); + if (!normalized) { + return undefined; + } + if (references.entries.has(normalized)) { + return `plugins.entries.${normalized}`; + } + if (references.allow.has(normalized)) { + return "plugins.allow"; + } + if (references.deny.has(normalized)) { + return "plugins.deny"; + } + const slotId = references.slots.get(normalized); + if (slotId) { + return `plugins.slots.${slotId}`; + } + return undefined; +} + export const __testing = { mapZodIssueToConfigIssue, }; @@ -830,6 +911,7 @@ function validateConfigObjectWithPluginsBase( const warnings: ConfigValidationIssue[] = []; const hasExplicitPluginsConfig = isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, "plugins"); + const explicitPluginReferences = collectExplicitPluginReferences(raw); const resolvePluginConfigIssuePath = (pluginId: string, errorPath: string): string => { const base = `plugins.entries.${pluginId}.config`; @@ -863,13 +945,16 @@ function validateConfigObjectWithPluginsBase( } registryDiagnosticsPushed = true; for (const diag of registry.diagnostics) { - let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins"; + const explicitPath = diag.pluginId + ? resolveExplicitPluginReferencePath(explicitPluginReferences, diag.pluginId) + : undefined; + let path = explicitPath ?? (diag.pluginId ? "plugins" : "plugins"); if (!diag.pluginId && diag.message.includes("plugin path not found")) { path = "plugins.load.paths"; } const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin"; const message = `${pluginLabel}: ${diag.message}`; - if (diag.level === "error") { + if (diag.level === "error" && (explicitPath || !diag.pluginId)) { issues.push({ path, message }); } else { warnings.push({ path, message });