mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user