fix(config): keep unrelated plugin diagnostics nonfatal (#83438)

* fix(config): keep unrelated plugin diagnostics nonfatal

* docs(changelog): mention config plugin validation fix
This commit is contained in:
scoootscooob
2026-05-17 23:11:15 -07:00
committed by GitHub
parent e96428b008
commit 5a7b861ea2
3 changed files with 185 additions and 3 deletions
+1
View File
@@ -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.
+97 -1
View File
@@ -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(
+87 -2
View File
@@ -50,6 +50,12 @@ const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:";
type UnknownIssueRecord = Record<string, unknown>;
type ConfigPathSegment = string | number;
type ExplicitPluginReferences = {
entries: Set<string>;
allow: Set<string>;
deny: Set<string>;
slots: Map<string, string>;
};
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 });