fix: tolerate stale channel plugin config

This commit is contained in:
Peter Steinberger
2026-04-27 12:47:44 +01:00
parent c0ea89cfd2
commit 22a51de422
4 changed files with 165 additions and 2 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Agents/tools: ignore volatile `exec` runtime metadata when comparing tool-loop outcomes, so enabled loop detection can stop repeated identical shell-command results instead of resetting on duration, PID, session, or cwd changes. Fixes #34574; supersedes #41502. Thanks @gucasbrg and @Zcg2021.
- Agents/fallback: classify internal live-session model switch conflicts as unknown fallback failures instead of provider overloads, preventing local vLLM endpoints from receiving misleading overloaded cooldowns. Refs #63229. Thanks @clawdia-lobster.
- Discord: let thread sessions inherit the parent channel's session-level `/model` override as a model-only fallback without enabling parent transcript inheritance. Fixes #72755. Thanks @solavrc.
- Gateway/plugins: skip stale configured channels whose matching plugin is no longer discoverable, point cleanup at `openclaw doctor --fix`, and keep unrelated channel typos fatal so one missing channel plugin no longer crash-loops the Gateway. Fixes #53311. Thanks @futhgar.
- Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top.
- Agents/Qwen: preserve exact custom `modelstudio` provider configs with foreign `api` owners so explicit OpenAI-compatible Model Studio endpoints no longer get normalized into the bundled Qwen plugin path. Fixes #64483. Thanks @FiredMosquito831.
- MCP/bundle-mcp: normalize CLI-native `type: "http"` MCP server entries to OpenClaw `transport: "streamable-http"` on save, repair existing configs with doctor, and keep embedded Pi from falling back to legacy SSE GET-first startup for those servers. Fixes #72757. Thanks @Studioscale.

View File

@@ -61,6 +61,12 @@ 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`.
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.
Run `openclaw doctor --fix` to remove the stale channel/plugin entries; unknown
channel keys without stale-plugin evidence still fail validation so typos stay
visible.
Packaged OpenClaw installs do not eagerly install every bundled plugin's
runtime dependency tree. When a bundled OpenClaw-owned plugin is active from

View File

@@ -266,6 +266,104 @@ describe("config plugin validation", () => {
}
});
it("warns instead of failing for stale channel config backed by missing plugin refs", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
channels: {
"missing-chat": { token: "stale" },
},
plugins: {
allow: ["missing-chat"],
entries: { "missing-chat": { enabled: true } },
},
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.warnings).toContainEqual({
path: "channels.missing-chat",
message:
"unknown channel id: missing-chat (stale channel plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)",
});
expect(res.warnings).toContainEqual({
path: "plugins.allow",
message:
"plugin not found: missing-chat (stale config entry ignored; remove it from plugins config)",
});
expect(res.warnings).toContainEqual({
path: "plugins.entries.missing-chat",
message:
"plugin not found: missing-chat (stale config entry ignored; remove it from plugins config)",
});
});
it("keeps unknown channel typos fatal when there is no stale plugin evidence", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
channels: {
telegarm: { botToken: "typo" },
},
plugins: {
allow: ["telegram"],
},
});
expect(res.ok).toBe(false);
if (res.ok) {
return;
}
expect(res.issues).toContainEqual({
path: "channels.telegarm",
message: "unknown channel id: telegarm",
});
expect(res.warnings).not.toContainEqual(expect.objectContaining({ path: "channels.telegarm" }));
});
it("uses persisted installed-plugin records as stale channel evidence", async () => {
const installedPluginIndexPath = path.join(suiteHome, ".openclaw", "plugins", "installs.json");
await mkdirSafe(path.dirname(installedPluginIndexPath));
await fs.writeFile(
installedPluginIndexPath,
JSON.stringify(
{
installRecords: {
"missing-sms": {
source: "npm",
spec: "missing-sms@1.0.0",
installedAt: "2026-04-12T00:00:00.000Z",
},
},
plugins: [],
},
null,
2,
),
"utf-8",
);
try {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
channels: {
"missing-sms": { token: "stale" },
},
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.warnings).toContainEqual({
path: "channels.missing-sms",
message:
"unknown channel id: missing-sms (stale channel plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)",
});
} finally {
await fs.rm(installedPluginIndexPath, { force: true });
}
});
it("warns with actionable guidance when a runtime command name is used in plugins.allow", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },

View File

@@ -4,6 +4,7 @@ import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/ids.js";
import { withBundledPluginAllowlistCompat } from "../plugins/bundled-compat.js";
import {
normalizePluginsConfig,
normalizePluginId,
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
@@ -12,6 +13,7 @@ import {
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
} from "../plugins/doctor-contract-registry.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js";
import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
@@ -926,6 +928,54 @@ function validateConfigObjectWithPluginsBase(
let channelsCloned = false;
let pluginsCloned = false;
let pluginEntriesCloned = false;
let installedPluginRecordIds: Set<string> | undefined;
const ensureInstalledPluginRecordIds = (): Set<string> => {
if (installedPluginRecordIds) {
return installedPluginRecordIds;
}
try {
installedPluginRecordIds = new Set(
Object.keys(loadInstalledPluginIndexInstallRecordsSync({ env: opts.env })).map(
normalizePluginId,
),
);
} catch {
installedPluginRecordIds = new Set();
}
return installedPluginRecordIds;
};
const hasStalePluginEvidenceForUnknownChannel = (channelId: string): boolean => {
const normalizedChannelId = normalizePluginId(channelId);
if (!normalizedChannelId || ensureKnownIds().has(normalizedChannelId)) {
return false;
}
const pluginConfig = config.plugins;
if (
Array.isArray(pluginConfig?.allow) &&
pluginConfig.allow.some((pluginId) => normalizePluginId(pluginId) === normalizedChannelId)
) {
return true;
}
if (
isRecord(pluginConfig?.entries) &&
Object.keys(pluginConfig.entries).some(
(pluginId) => normalizePluginId(pluginId) === normalizedChannelId,
)
) {
return true;
}
if (
isRecord(pluginConfig?.installs) &&
Object.keys(pluginConfig.installs).some(
(pluginId) => normalizePluginId(pluginId) === normalizedChannelId,
)
) {
return true;
}
return ensureInstalledPluginRecordIds().has(normalizedChannelId);
};
const replaceChannelConfig = (channelId: string, nextValue: unknown) => {
if (!channelsCloned) {
@@ -983,10 +1033,18 @@ function validateConfigObjectWithPluginsBase(
}
}
if (!allowedChannels.has(trimmed)) {
issues.push({
const issue = {
path: `channels.${trimmed}`,
message: `unknown channel id: ${trimmed}`,
});
};
if (hasStalePluginEvidenceForUnknownChannel(trimmed)) {
warnings.push({
...issue,
message: `${issue.message} (stale channel plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)`,
});
} else {
issues.push(issue);
}
continue;
}