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

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe.
- Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.
- Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.
- Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.
- Agents/reasoning: recover fully wrapped unclosed `<think>` replies that would otherwise sanitize to empty text while keeping strict stripping for closed reasoning blocks and unclosed tails after visible text. Fixes #37696; supersedes #51915. Thanks @druide67 and @okuyam2y.
- Control UI/Gateway: bind WebChat handshakes to their active socket and reject post-close server registrations, so aborted connects no longer leave zombie clients or misleading duplicate WebSocket connection logs. Fixes #72753. Thanks @LumenFromTheFuture.
- Agents/fallback: split ambiguous provider failures into `empty_response`, `no_error_details`, and `unclassified`, and add flat fallback-step fields to structured fallback logs so primary-model failures stay visible when later fallbacks also fail. Fixes #71922; refs #71744. Thanks @andyk-ms and @nikolaykazakovvs-ux.

View File

@@ -45,6 +45,7 @@ Notes:
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`; it can also be a path-list such as `/opt/openclaw/plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps`, where earlier roots are read-only lookup layers and the final root is the repair target.
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
- Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.<id>` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running.
- Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup.
- Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.<provider>`.
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.

View File

@@ -79,7 +79,7 @@ Bare package names are checked against ClawHub first, then npm. Treat plugin ins
<Accordion title="Config includes and invalid-config recovery">
If your `plugins` section is backed by a single-file `$include`, `plugins install/update/enable/disable/uninstall` write through to that included file and leave `openclaw.json` untouched. Root includes, include arrays, and includes with sibling overrides fail closed instead of flattening. See [Config includes](/gateway/configuration) for the supported shapes.
If config is invalid, `plugins install` normally fails closed and tells you to run `openclaw doctor --fix` first. The only documented exception is a narrow bundled-plugin recovery path for plugins that explicitly opt into `openclaw.install.allowInvalidConfigRecovery`.
If config is invalid during install, `plugins install` normally fails closed and tells you to run `openclaw doctor --fix` first. During Gateway startup, invalid config for one plugin is isolated to that plugin so other channels and plugins can keep running; `openclaw doctor --fix` can quarantine the invalid plugin entry. The only documented install-time exception is a narrow bundled-plugin recovery path for plugins that explicitly opt into `openclaw.install.allowInvalidConfigRecovery`.
</Accordion>
<Accordion title="--force and reinstall vs update">

View File

@@ -61,6 +61,11 @@ If config is invalid, install normally fails closed and points you at
`openclaw doctor --fix`. The only recovery exception is a narrow bundled-plugin
reinstall path for plugins that opt into
`openclaw.install.allowInvalidConfigRecovery`.
During Gateway startup, invalid config for one plugin is isolated to that plugin:
startup logs the `plugins.entries.<id>.config` issue, skips that plugin during
load, and keeps other plugins and channels online. Run `openclaw doctor --fix`
to quarantine the bad plugin config by disabling that plugin entry and removing
its invalid config payload; the normal config backup keeps the previous values.
When a channel config references a plugin that is no longer discoverable but the
same stale plugin id remains in plugin config or install records, Gateway startup
logs warnings and skips that channel instead of blocking every other channel.
@@ -203,7 +208,7 @@ or use `openclaw gateway restart` against the running Gateway.
<Accordion title="Plugin states: disabled vs missing vs invalid">
- **Disabled**: plugin exists but enablement rules turned it off. Config is preserved.
- **Missing**: config references a plugin id that discovery did not find.
- **Invalid**: plugin exists but its config does not match the declared schema.
- **Invalid**: plugin exists but its config does not match the declared schema. Gateway startup skips only that plugin; `openclaw doctor --fix` can quarantine the invalid entry by disabling it and removing its config payload.
</Accordion>
## Discovery and precedence

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, {

View File

@@ -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: [] });
});
});

View File

@@ -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(", ")})`,
),
],
};
}

View File

@@ -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" },

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 } : {}),
};
}