fix: quarantine invalid plugin configs

This commit is contained in:
Peter Steinberger
2026-04-27 13:14:43 +01:00
parent b1e530b204
commit 4260bb0418
9 changed files with 360 additions and 7 deletions
+2
View File
@@ -11,6 +11,7 @@ import {
} from "./shared/config-mutation-state.js";
import { scanEmptyAllowlistPolicyWarnings } from "./shared/empty-allowlist-scan.js";
import { maybeRepairExecSafeBinProfiles } from "./shared/exec-safe-bins.js";
import { maybeRepairInvalidPluginConfig } from "./shared/invalid-plugin-config.js";
import { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sender.js";
import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js";
import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js";
@@ -58,6 +59,7 @@ export async function runDoctorRepairSequence(params: {
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
applyMutation(maybeRepairStalePluginConfig(state.candidate, env));
applyMutation(maybeRepairInvalidPluginConfig(state.candidate));
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, {
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
const validationMocks = vi.hoisted(() => ({
validateConfigObjectWithPlugins: vi.fn(),
}));
vi.mock("../../../config/validation.js", () => ({
validateConfigObjectWithPlugins: validationMocks.validateConfigObjectWithPlugins,
}));
const { maybeRepairInvalidPluginConfig } = await import("./invalid-plugin-config.js");
describe("doctor invalid plugin config repair", () => {
beforeEach(() => {
validationMocks.validateConfigObjectWithPlugins.mockReset();
});
it("disables plugins and removes invalid config payloads", () => {
validationMocks.validateConfigObjectWithPlugins.mockReturnValue({
ok: false,
warnings: [],
issues: [
{
path: "plugins.entries.community-feedback.config.communityRepo",
message: 'invalid config: must match pattern "^[^/]+/[^/]+$"',
},
],
});
const result = maybeRepairInvalidPluginConfig({
plugins: {
entries: {
"community-feedback": {
enabled: true,
config: {
communityRepo: "",
},
},
whatsapp: {
enabled: true,
config: {
session: "keep",
},
},
},
},
} as OpenClawConfig);
expect(result.changes).toEqual([
"- plugins.entries: quarantined 1 invalid plugin config (community-feedback)",
]);
expect(result.config.plugins?.entries?.["community-feedback"]).toEqual({
enabled: false,
});
expect(result.config.plugins?.entries?.whatsapp).toEqual({
enabled: true,
config: {
session: "keep",
},
});
});
it("handles slash-delimited plugin ids", () => {
validationMocks.validateConfigObjectWithPlugins.mockReturnValue({
ok: false,
warnings: [],
issues: [
{
path: "plugins.entries.pack/one.config.repo",
message: "invalid config: must NOT have fewer than 1 characters",
},
],
});
const result = maybeRepairInvalidPluginConfig({
plugins: {
entries: {
"pack/one": {
config: {
repo: "",
},
},
},
},
} as OpenClawConfig);
expect(result.config.plugins?.entries?.["pack/one"]).toEqual({
enabled: false,
});
});
it("disables plugins whose required config payload is missing", () => {
validationMocks.validateConfigObjectWithPlugins.mockReturnValue({
ok: false,
warnings: [],
issues: [
{
path: "plugins.entries.community-feedback.config.communityRepo",
message: 'invalid config: must have required property "communityRepo"',
},
],
});
const result = maybeRepairInvalidPluginConfig({
plugins: {
entries: {
"community-feedback": {
enabled: true,
hooks: {
allowPromptInjection: true,
},
},
},
},
} as OpenClawConfig);
expect(result.changes).toEqual([
"- plugins.entries: quarantined 1 invalid plugin config (community-feedback)",
]);
expect(result.config.plugins?.entries?.["community-feedback"]).toEqual({
enabled: false,
hooks: {
allowPromptInjection: true,
},
});
});
it("ignores non-plugin validation issues", () => {
validationMocks.validateConfigObjectWithPlugins.mockReturnValue({
ok: false,
warnings: [],
issues: [
{
path: "gateway.mode",
message: "Expected 'local' or 'remote'",
},
],
});
const cfg = {
gateway: {
mode: "invalid",
},
} as unknown as OpenClawConfig;
expect(maybeRepairInvalidPluginConfig(cfg)).toEqual({ config: cfg, changes: [] });
});
});
@@ -0,0 +1,78 @@
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { validateConfigObjectWithPlugins } from "../../../config/validation.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { asObjectRecord } from "./object.js";
type InvalidPluginConfigHit = {
pluginId: string;
pathLabel: string;
};
const PLUGIN_CONFIG_ISSUE_RE = /^plugins\.entries\.([^.]+)\.config(?:\.|$)/;
function scanInvalidPluginConfig(cfg: OpenClawConfig): InvalidPluginConfigHit[] {
const validation = validateConfigObjectWithPlugins(cfg);
if (validation.ok) {
return [];
}
const hits: InvalidPluginConfigHit[] = [];
const seen = new Set<string>();
for (const issue of validation.issues) {
if (!issue.message.startsWith("invalid config:")) {
continue;
}
const match = issue.path.match(PLUGIN_CONFIG_ISSUE_RE);
const pluginId = match?.[1];
if (!pluginId || seen.has(pluginId)) {
continue;
}
seen.add(pluginId);
hits.push({
pluginId,
pathLabel: `plugins.entries.${pluginId}.config`,
});
}
return hits;
}
export function maybeRepairInvalidPluginConfig(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
const hits = scanInvalidPluginConfig(cfg);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const entries = asObjectRecord(next.plugins?.entries);
if (!entries) {
return { config: cfg, changes: [] };
}
const quarantined: string[] = [];
for (const hit of hits) {
const entry = asObjectRecord(entries[hit.pluginId]);
if (!entry) {
continue;
}
if ("config" in entry) {
delete entry.config;
}
entry.enabled = false;
quarantined.push(hit.pluginId);
}
if (quarantined.length === 0) {
return { config: cfg, changes: [] };
}
return {
config: next,
changes: [
sanitizeForLog(
`- plugins.entries: quarantined ${quarantined.length} invalid plugin config${quarantined.length === 1 ? "" : "s"} (${quarantined.join(", ")})`,
),
],
};
}
@@ -8,6 +8,12 @@ vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: vi.fn(),
recoverConfigFromLastKnownGood: vi.fn(),
recoverConfigFromJsonRootSuffix: vi.fn(),
isPluginLocalInvalidConfigSnapshot: vi.fn((snapshot: ConfigFileSnapshot) => {
if (snapshot.valid || snapshot.legacyIssues.length > 0 || snapshot.issues.length === 0) {
return false;
}
return snapshot.issues.every((issue) => issue.path.startsWith("plugins.entries."));
}),
shouldAttemptLastKnownGoodRecovery: vi.fn((snapshot: ConfigFileSnapshot) => {
if (snapshot.valid) {
return false;
@@ -125,7 +131,7 @@ describe("gateway startup config recovery", () => {
expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled();
});
it("does not restore last-known-good for plugin-local startup invalidity", async () => {
it("continues startup in degraded mode for plugin-local startup invalidity", async () => {
const invalidSnapshot = buildTestConfigSnapshot({
path: configPath,
exists: true,
@@ -171,16 +177,82 @@ describe("gateway startup config recovery", () => {
minimalTestGateway: true,
log,
}),
).rejects.toThrow(`Invalid config at ${configPath}.`);
).resolves.toEqual({
snapshot: expect.objectContaining({
valid: true,
issues: [],
warnings: invalidSnapshot.issues,
}),
wroteConfig: false,
degradedPluginConfig: true,
});
expect(configIo.recoverConfigFromLastKnownGood).not.toHaveBeenCalled();
expect(configIo.recoverConfigFromJsonRootSuffix).toHaveBeenCalledWith(invalidSnapshot);
expect(configIo.recoverConfigFromJsonRootSuffix).not.toHaveBeenCalled();
expect(log.warn).toHaveBeenCalledWith(
`gateway: last-known-good recovery skipped for plugin-local config invalidity: ${configPath}`,
`gateway: skipped plugin config validation issue at plugins.entries.feishu: plugin feishu: plugin requires OpenClaw >=2026.4.23, but this host is 2026.4.22; skipping load. Run "openclaw doctor --fix" to quarantine the plugin config.`,
);
expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled();
});
it("keeps mixed plugin and core startup invalidity fatal", async () => {
const invalidSnapshot = buildTestConfigSnapshot({
path: configPath,
exists: true,
raw: `${JSON.stringify({
gateway: { mode: "invalid" },
plugins: {
entries: {
feishu: { enabled: true },
},
},
})}\n`,
parsed: {
gateway: { mode: "invalid" },
plugins: {
entries: {
feishu: { enabled: true },
},
},
},
valid: false,
config: {
gateway: { mode: "invalid" },
plugins: {
entries: {
feishu: { enabled: true },
},
},
} as unknown as OpenClawConfig,
issues: [
{
path: "gateway.mode",
message: "Expected 'local' or 'remote'",
},
{
path: "plugins.entries.feishu.config.token",
message: "invalid config: must be string",
},
],
legacyIssues: [],
});
vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot);
vi.mocked(configIo.recoverConfigFromLastKnownGood).mockResolvedValueOnce(false);
vi.mocked(configIo.recoverConfigFromJsonRootSuffix).mockResolvedValueOnce(false);
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: true,
log: { info: vi.fn(), warn: vi.fn() },
}),
).rejects.toThrow(`Invalid config at ${configPath}.`);
expect(configIo.recoverConfigFromLastKnownGood).toHaveBeenCalledWith({
snapshot: invalidSnapshot,
reason: "startup-invalid-config",
});
});
it("skips providers with stale model api enum values during startup", async () => {
const config = {
gateway: { mode: "local" },
+47 -1
View File
@@ -10,6 +10,7 @@ import {
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
replaceConfigFile,
isPluginLocalInvalidConfigSnapshot,
shouldAttemptLastKnownGoodRecovery,
validateConfigObjectWithPlugins,
} from "../config/config.js";
@@ -59,6 +60,7 @@ export type GatewayStartupConfigSnapshotLoadResult = {
snapshot: ConfigFileSnapshot;
wroteConfig: boolean;
degradedProviderApi?: boolean;
degradedPluginConfig?: boolean;
};
const MODEL_PROVIDER_API_PATH_RE = /^models\.providers\.([^.]+)\.api$/;
@@ -151,6 +153,37 @@ function resolveGatewayStartupConfigWithoutInvalidModelProviders(params: {
};
}
function resolveGatewayStartupConfigWithoutInvalidPluginEntries(params: {
snapshot: ConfigFileSnapshot;
log: GatewayStartupLog;
}): ConfigFileSnapshot | null {
if (!isPluginLocalInvalidConfigSnapshot(params.snapshot)) {
return null;
}
const validated = validateConfigObjectWithPlugins(params.snapshot.sourceConfig, {
pluginValidation: "skip",
});
if (!validated.ok) {
return null;
}
const runtimeConfig = materializeRuntimeConfig(validated.config, "load");
for (const issue of params.snapshot.issues) {
params.log.warn(
`gateway: skipped plugin config validation issue at ${issue.path}: ${issue.message}. Run "openclaw doctor --fix" to quarantine the plugin config.`,
);
}
return {
...params.snapshot,
sourceConfig: asResolvedSourceConfig(validated.config),
resolved: asResolvedSourceConfig(validated.config),
valid: true,
runtimeConfig,
config: runtimeConfig,
issues: [],
warnings: [...params.snapshot.warnings, ...params.snapshot.issues],
};
}
export async function loadGatewayStartupConfigSnapshot(params: {
minimalTestGateway: boolean;
log: GatewayStartupLog;
@@ -158,6 +191,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
let configSnapshot = await readConfigFileSnapshot();
let wroteConfig = false;
let degradedStartupConfig = false;
let degradedPluginConfig = false;
if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
throw new Error(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
@@ -174,6 +208,16 @@ export async function loadGatewayStartupConfigSnapshot(params: {
configSnapshot = providerApiPrunedSnapshot;
}
}
if (!configSnapshot.valid) {
const pluginConfigDegradedSnapshot = resolveGatewayStartupConfigWithoutInvalidPluginEntries({
snapshot: configSnapshot,
log: params.log,
});
if (pluginConfigDegradedSnapshot) {
degradedPluginConfig = true;
configSnapshot = pluginConfigDegradedSnapshot;
}
}
if (!configSnapshot.valid) {
const canRecoverFromLastKnownGood = shouldAttemptLastKnownGoodRecovery(configSnapshot);
const recovered = canRecoverFromLastKnownGood
@@ -214,7 +258,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
}
const autoEnable =
params.minimalTestGateway || degradedStartupConfig
params.minimalTestGateway || degradedStartupConfig || degradedPluginConfig
? { config: configSnapshot.config, changes: [] as string[] }
: applyPluginAutoEnable({ config: configSnapshot.config, env: process.env });
if (autoEnable.changes.length === 0) {
@@ -222,6 +266,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
snapshot: configSnapshot,
wroteConfig,
...(degradedStartupConfig ? { degradedProviderApi: true } : {}),
...(degradedPluginConfig ? { degradedPluginConfig: true } : {}),
};
}
@@ -244,6 +289,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
snapshot: configSnapshot,
wroteConfig,
...(degradedStartupConfig ? { degradedProviderApi: true } : {}),
...(degradedPluginConfig ? { degradedPluginConfig: true } : {}),
};
}