diff --git a/CHANGELOG.md b/CHANGELOG.md index 9978102e522..747d37adfc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7774a1db9ca..0d27e02a68e 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index e17abb919c2..4318a1deb79 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -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" }] }, diff --git a/src/config/validation.ts b/src/config/validation.ts index 26e86849285..2e0530da52b 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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 | undefined; + + const ensureInstalledPluginRecordIds = (): Set => { + 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; }