mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:20:44 +00:00
fix: quarantine invalid plugin configs
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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 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 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 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.
|
- 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>`.
|
- 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.
|
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.
|
||||||
|
|||||||
@@ -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">
|
<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 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>
|
||||||
<Accordion title="--force and reinstall vs update">
|
<Accordion title="--force and reinstall vs update">
|
||||||
|
|||||||
@@ -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
|
`openclaw doctor --fix`. The only recovery exception is a narrow bundled-plugin
|
||||||
reinstall path for plugins that opt into
|
reinstall path for plugins that opt into
|
||||||
`openclaw.install.allowInvalidConfigRecovery`.
|
`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
|
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
|
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.
|
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">
|
<Accordion title="Plugin states: disabled vs missing vs invalid">
|
||||||
- **Disabled**: plugin exists but enablement rules turned it off. Config is preserved.
|
- **Disabled**: plugin exists but enablement rules turned it off. Config is preserved.
|
||||||
- **Missing**: config references a plugin id that discovery did not find.
|
- **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>
|
</Accordion>
|
||||||
|
|
||||||
## Discovery and precedence
|
## Discovery and precedence
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "./shared/config-mutation-state.js";
|
} from "./shared/config-mutation-state.js";
|
||||||
import { scanEmptyAllowlistPolicyWarnings } from "./shared/empty-allowlist-scan.js";
|
import { scanEmptyAllowlistPolicyWarnings } from "./shared/empty-allowlist-scan.js";
|
||||||
import { maybeRepairExecSafeBinProfiles } from "./shared/exec-safe-bins.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 { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sender.js";
|
||||||
import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js";
|
import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js";
|
||||||
import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js";
|
import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js";
|
||||||
@@ -58,6 +59,7 @@ export async function runDoctorRepairSequence(params: {
|
|||||||
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
|
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
|
||||||
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
|
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
|
||||||
applyMutation(maybeRepairStalePluginConfig(state.candidate, env));
|
applyMutation(maybeRepairStalePluginConfig(state.candidate, env));
|
||||||
|
applyMutation(maybeRepairInvalidPluginConfig(state.candidate));
|
||||||
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
|
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
|
||||||
|
|
||||||
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, {
|
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, {
|
||||||
|
|||||||
148
src/commands/doctor/shared/invalid-plugin-config.test.ts
Normal file
148
src/commands/doctor/shared/invalid-plugin-config.test.ts
Normal 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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/commands/doctor/shared/invalid-plugin-config.ts
Normal file
78
src/commands/doctor/shared/invalid-plugin-config.ts
Normal 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(", ")})`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ vi.mock("../config/config.js", () => ({
|
|||||||
readConfigFileSnapshot: vi.fn(),
|
readConfigFileSnapshot: vi.fn(),
|
||||||
recoverConfigFromLastKnownGood: vi.fn(),
|
recoverConfigFromLastKnownGood: vi.fn(),
|
||||||
recoverConfigFromJsonRootSuffix: 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) => {
|
shouldAttemptLastKnownGoodRecovery: vi.fn((snapshot: ConfigFileSnapshot) => {
|
||||||
if (snapshot.valid) {
|
if (snapshot.valid) {
|
||||||
return false;
|
return false;
|
||||||
@@ -125,7 +131,7 @@ describe("gateway startup config recovery", () => {
|
|||||||
expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled();
|
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({
|
const invalidSnapshot = buildTestConfigSnapshot({
|
||||||
path: configPath,
|
path: configPath,
|
||||||
exists: true,
|
exists: true,
|
||||||
@@ -171,16 +177,82 @@ describe("gateway startup config recovery", () => {
|
|||||||
minimalTestGateway: true,
|
minimalTestGateway: true,
|
||||||
log,
|
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.recoverConfigFromLastKnownGood).not.toHaveBeenCalled();
|
||||||
expect(configIo.recoverConfigFromJsonRootSuffix).toHaveBeenCalledWith(invalidSnapshot);
|
expect(configIo.recoverConfigFromJsonRootSuffix).not.toHaveBeenCalled();
|
||||||
expect(log.warn).toHaveBeenCalledWith(
|
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();
|
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 () => {
|
it("skips providers with stale model api enum values during startup", async () => {
|
||||||
const config = {
|
const config = {
|
||||||
gateway: { mode: "local" },
|
gateway: { mode: "local" },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
recoverConfigFromLastKnownGood,
|
recoverConfigFromLastKnownGood,
|
||||||
recoverConfigFromJsonRootSuffix,
|
recoverConfigFromJsonRootSuffix,
|
||||||
replaceConfigFile,
|
replaceConfigFile,
|
||||||
|
isPluginLocalInvalidConfigSnapshot,
|
||||||
shouldAttemptLastKnownGoodRecovery,
|
shouldAttemptLastKnownGoodRecovery,
|
||||||
validateConfigObjectWithPlugins,
|
validateConfigObjectWithPlugins,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
@@ -59,6 +60,7 @@ export type GatewayStartupConfigSnapshotLoadResult = {
|
|||||||
snapshot: ConfigFileSnapshot;
|
snapshot: ConfigFileSnapshot;
|
||||||
wroteConfig: boolean;
|
wroteConfig: boolean;
|
||||||
degradedProviderApi?: boolean;
|
degradedProviderApi?: boolean;
|
||||||
|
degradedPluginConfig?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODEL_PROVIDER_API_PATH_RE = /^models\.providers\.([^.]+)\.api$/;
|
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: {
|
export async function loadGatewayStartupConfigSnapshot(params: {
|
||||||
minimalTestGateway: boolean;
|
minimalTestGateway: boolean;
|
||||||
log: GatewayStartupLog;
|
log: GatewayStartupLog;
|
||||||
@@ -158,6 +191,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
|||||||
let configSnapshot = await readConfigFileSnapshot();
|
let configSnapshot = await readConfigFileSnapshot();
|
||||||
let wroteConfig = false;
|
let wroteConfig = false;
|
||||||
let degradedStartupConfig = false;
|
let degradedStartupConfig = false;
|
||||||
|
let degradedPluginConfig = false;
|
||||||
if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
|
if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
|
"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;
|
configSnapshot = providerApiPrunedSnapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!configSnapshot.valid) {
|
||||||
|
const pluginConfigDegradedSnapshot = resolveGatewayStartupConfigWithoutInvalidPluginEntries({
|
||||||
|
snapshot: configSnapshot,
|
||||||
|
log: params.log,
|
||||||
|
});
|
||||||
|
if (pluginConfigDegradedSnapshot) {
|
||||||
|
degradedPluginConfig = true;
|
||||||
|
configSnapshot = pluginConfigDegradedSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!configSnapshot.valid) {
|
if (!configSnapshot.valid) {
|
||||||
const canRecoverFromLastKnownGood = shouldAttemptLastKnownGoodRecovery(configSnapshot);
|
const canRecoverFromLastKnownGood = shouldAttemptLastKnownGoodRecovery(configSnapshot);
|
||||||
const recovered = canRecoverFromLastKnownGood
|
const recovered = canRecoverFromLastKnownGood
|
||||||
@@ -214,7 +258,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoEnable =
|
const autoEnable =
|
||||||
params.minimalTestGateway || degradedStartupConfig
|
params.minimalTestGateway || degradedStartupConfig || degradedPluginConfig
|
||||||
? { config: configSnapshot.config, changes: [] as string[] }
|
? { config: configSnapshot.config, changes: [] as string[] }
|
||||||
: applyPluginAutoEnable({ config: configSnapshot.config, env: process.env });
|
: applyPluginAutoEnable({ config: configSnapshot.config, env: process.env });
|
||||||
if (autoEnable.changes.length === 0) {
|
if (autoEnable.changes.length === 0) {
|
||||||
@@ -222,6 +266,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
|||||||
snapshot: configSnapshot,
|
snapshot: configSnapshot,
|
||||||
wroteConfig,
|
wroteConfig,
|
||||||
...(degradedStartupConfig ? { degradedProviderApi: true } : {}),
|
...(degradedStartupConfig ? { degradedProviderApi: true } : {}),
|
||||||
|
...(degradedPluginConfig ? { degradedPluginConfig: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +289,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
|||||||
snapshot: configSnapshot,
|
snapshot: configSnapshot,
|
||||||
wroteConfig,
|
wroteConfig,
|
||||||
...(degradedStartupConfig ? { degradedProviderApi: true } : {}),
|
...(degradedStartupConfig ? { degradedProviderApi: true } : {}),
|
||||||
|
...(degradedPluginConfig ? { degradedPluginConfig: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user