From fb28b02540621dfd62b666faaad45e189d579c72 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 1 Apr 2026 14:42:36 +0530 Subject: [PATCH] fix: preserve bundled channel plugin compat (#58873) * fix: preserve bundled channel plugin compat * fix: preserve bundled channel plugin compat (#58873) * fix: scope channel plugin compat to bundled plugins (#58873) --- CHANGELOG.md | 1 + src/commands/doctor-config-flow.test.ts | 50 ++++++++++ .../doctor/shared/channel-plugin-blockers.ts | 96 +++++++++++++++++++ .../doctor/shared/preview-warnings.test.ts | 69 +++++++++++++ .../doctor/shared/preview-warnings.ts | 14 ++- src/plugins/config-state.test.ts | 26 +++++ src/plugins/config-state.ts | 3 +- src/plugins/loader.test.ts | 16 ++++ 8 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 src/commands/doctor/shared/channel-plugin-blockers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb7805a56f..85d3e5e902c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Sandbox/browser: compare browser runtime inspection against `agents.defaults.sandbox.browser.image` so `openclaw sandbox list --browser` stops reporting healthy browser containers as image mismatches. (#58759) Thanks @sandpile. - Exec/approvals: resume the original agent session after an approved async exec finishes, so external completion followups continue the task instead of waiting for a new user turn. (#58860) Thanks @Nanako0129. - Gateway/node pairing: create repair pairing requests when a paired node reconnects with allowlisted commands missing from its approved node record, refresh stale pending repair metadata, and surface paired node command metadata in `nodes status`/`describe` even while the node is offline. Fixes #58824. +- Channels/plugins: keep bundled channel plugins loadable from legacy `channels.` config even under restrictive plugin allowlists, and make `openclaw doctor` warn only on real plugin blockers instead of misleading setup guidance. (#58873) Thanks @obviyus ## 2026.3.31 diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index d55f7cf440e..d4381848ea3 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -190,6 +190,56 @@ describe("doctor config flow", () => { ).toBe(true); }); + it("shows plugin-blocked guidance instead of first-time Telegram guidance when telegram is explicitly disabled", async () => { + const doctorWarnings = await collectDoctorWarnings({ + channels: { + telegram: { + botToken: "123:abc", + groupPolicy: "allowlist", + }, + }, + plugins: { + entries: { + telegram: { + enabled: false, + }, + }, + }, + }); + + expect( + doctorWarnings.some((line) => + line.includes( + 'channels.telegram: channel is configured, but plugin "telegram" is disabled by plugins.entries.telegram.enabled=false.', + ), + ), + ).toBe(true); + expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false); + }); + + it("shows plugin-blocked guidance instead of first-time Telegram guidance when plugins are disabled globally", async () => { + const doctorWarnings = await collectDoctorWarnings({ + channels: { + telegram: { + botToken: "123:abc", + groupPolicy: "allowlist", + }, + }, + plugins: { + enabled: false, + }, + }); + + expect( + doctorWarnings.some((line) => + line.includes( + "channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally.", + ), + ), + ).toBe(true); + expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false); + }); + it("warns on mutable Zalouser group entries when dangerous name matching is disabled", async () => { const doctorWarnings = await collectDoctorWarnings({ channels: { diff --git a/src/commands/doctor/shared/channel-plugin-blockers.ts b/src/commands/doctor/shared/channel-plugin-blockers.ts new file mode 100644 index 00000000000..a33c673a0b6 --- /dev/null +++ b/src/commands/doctor/shared/channel-plugin-blockers.ts @@ -0,0 +1,96 @@ +import { listPotentialConfiguredChannelIds } from "../../../channels/config-presence.js"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { + normalizePluginsConfig, + resolveEffectiveEnableState, +} from "../../../plugins/config-state.js"; +import { loadPluginManifestRegistry } from "../../../plugins/manifest-registry.js"; +import { sanitizeForLog } from "../../../terminal/ansi.js"; + +export type ChannelPluginBlockerHit = { + channelId: string; + pluginId: string; + reason: "disabled in config" | "plugins disabled"; +}; + +export function scanConfiguredChannelPluginBlockers( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): ChannelPluginBlockerHit[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(cfg, env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + + const pluginsConfig = normalizePluginsConfig(cfg.plugins); + const registry = loadPluginManifestRegistry({ + config: cfg, + env, + }); + const hits: ChannelPluginBlockerHit[] = []; + + for (const plugin of registry.plugins) { + if (plugin.channels.length === 0) { + continue; + } + + const enableState = resolveEffectiveEnableState({ + id: plugin.id, + origin: plugin.origin, + config: pluginsConfig, + rootConfig: cfg, + enabledByDefault: plugin.enabledByDefault, + }); + if ( + enableState.enabled || + !enableState.reason || + (enableState.reason !== "disabled in config" && enableState.reason !== "plugins disabled") + ) { + continue; + } + + for (const channelId of plugin.channels) { + if (!configuredChannelIds.has(channelId)) { + continue; + } + hits.push({ + channelId, + pluginId: plugin.id, + reason: enableState.reason, + }); + } + } + + return hits; +} + +function formatReason(hit: ChannelPluginBlockerHit): string { + if (hit.reason === "disabled in config") { + return `plugin "${sanitizeForLog(hit.pluginId)}" is disabled by plugins.entries.${sanitizeForLog(hit.pluginId)}.enabled=false.`; + } + if (hit.reason === "plugins disabled") { + return `plugins.enabled=false blocks channel plugins globally.`; + } + return `plugin "${sanitizeForLog(hit.pluginId)}" is not loadable (${sanitizeForLog(hit.reason)}).`; +} + +export function collectConfiguredChannelPluginBlockerWarnings( + hits: ChannelPluginBlockerHit[], +): string[] { + return hits.map( + (hit) => + `- channels.${sanitizeForLog(hit.channelId)}: channel is configured, but ${formatReason(hit)} Fix plugin enablement before relying on setup guidance for this channel.`, + ); +} + +export function isWarningBlockedByChannelPlugin( + warning: string, + hits: ChannelPluginBlockerHit[], +): boolean { + return hits.some((hit) => { + const prefix = `channels.${sanitizeForLog(hit.channelId)}`; + return warning.includes(`${prefix}:`) || warning.includes(`${prefix}.`); + }); +} diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 4308ff3dd5c..d5822615e2c 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -20,6 +20,13 @@ function manifest(id: string): PluginManifestRecord { }; } +function channelManifest(id: string, channelId: string): PluginManifestRecord { + return { + ...manifest(id), + channels: [channelId], + }; +} + describe("doctor preview warnings", () => { beforeEach(() => { vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ @@ -161,4 +168,66 @@ describe("doctor preview warnings", () => { expect(warnings[0]).toContain("Auto-removal is paused"); expect(warnings[0]).toContain('rerun "openclaw doctor --fix"'); }); + + it("warns when a configured channel plugin is disabled explicitly", () => { + vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ + plugins: [channelManifest("telegram", "telegram")], + diagnostics: [], + }); + + const warnings = collectDoctorPreviewWarnings({ + cfg: { + channels: { + telegram: { + botToken: "123:abc", + groupPolicy: "allowlist", + }, + }, + plugins: { + entries: { + telegram: { + enabled: false, + }, + }, + }, + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(warnings).toEqual([ + expect.stringContaining( + 'channels.telegram: channel is configured, but plugin "telegram" is disabled by plugins.entries.telegram.enabled=false.', + ), + ]); + expect(warnings[0]).not.toContain("first-time setup mode"); + }); + + it("warns when channel plugins are blocked globally", () => { + vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ + plugins: [channelManifest("telegram", "telegram")], + diagnostics: [], + }); + + const warnings = collectDoctorPreviewWarnings({ + cfg: { + channels: { + telegram: { + botToken: "123:abc", + groupPolicy: "allowlist", + }, + }, + plugins: { + enabled: false, + }, + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(warnings).toEqual([ + expect.stringContaining( + "channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally.", + ), + ]); + expect(warnings[0]).not.toContain("first-time setup mode"); + }); }); diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 217cf830b79..ce244b01a88 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -13,6 +13,11 @@ import { collectBundledPluginLoadPathWarnings, scanBundledPluginLoadPathMigrations, } from "./bundled-plugin-load-paths.js"; +import { + collectConfiguredChannelPluginBlockerWarnings, + isWarningBlockedByChannelPlugin, + scanConfiguredChannelPluginBlockers, +} from "./channel-plugin-blockers.js"; import { scanEmptyAllowlistPolicyWarnings } from "./empty-allowlist-scan.js"; import { collectExecSafeBinCoverageWarnings, @@ -40,6 +45,13 @@ export function collectDoctorPreviewWarnings(params: { }): string[] { const warnings: string[] = []; + const channelPluginBlockerHits = scanConfiguredChannelPluginBlockers(params.cfg, process.env); + if (channelPluginBlockerHits.length > 0) { + warnings.push( + collectConfiguredChannelPluginBlockerWarnings(channelPluginBlockerHits).join("\n"), + ); + } + const telegramHits = scanTelegramAllowFromUsernameEntries(params.cfg); if (telegramHits.length > 0) { warnings.push( @@ -94,7 +106,7 @@ export function collectDoctorPreviewWarnings(params: { const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, { doctorFixCommand: params.doctorFixCommand, extraWarningsForAccount: collectTelegramEmptyAllowlistExtraWarnings, - }); + }).filter((warning) => !isWarningBlockedByChannelPlugin(warning, channelPluginBlockerHits)); if (emptyAllowlistWarnings.length > 0) { warnings.push(emptyAllowlistWarnings.map((line) => sanitizeForLog(line)).join("\n")); } diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index a6e1a7fc7fb..66d22f2ad3d 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -158,8 +158,25 @@ describe("resolveEffectiveEnableState", () => { }); } + function resolveConfigOriginTelegramState(config: Parameters[0]) { + const normalized = normalizePluginsConfig(config); + return resolveEffectiveEnableState({ + id: "telegram", + origin: "config", + config: normalized, + rootConfig: { + channels: { + telegram: { + enabled: true, + }, + }, + }, + }); + } + it.each([ [{ enabled: true }, { enabled: true }], + [{ enabled: true, allow: ["browser"] as string[] }, { enabled: true }], [ { enabled: true, @@ -174,6 +191,15 @@ describe("resolveEffectiveEnableState", () => { ] as const)("resolves bundled telegram state for %o", (config, expected) => { expect(resolveBundledTelegramState(config)).toEqual(expected); }); + + it("does not bypass allowlists for non-bundled plugins that reuse a channel id", () => { + expect( + resolveConfigOriginTelegramState({ + enabled: true, + allow: ["browser"] as string[], + }), + ).toEqual({ enabled: false, reason: "not in allowlist" }); + }); }); describe("resolveEnableState", () => { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index dc6aa16f9c7..78354a590f6 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -302,8 +302,9 @@ export function resolveEffectiveEnableState(params: { }): { enabled: boolean; reason?: string } { const base = resolveEnableState(params.id, params.origin, params.config, params.enabledByDefault); if ( + params.origin === "bundled" && !base.enabled && - base.reason === "bundled (disabled by default)" && + (base.reason === "bundled (disabled by default)" || base.reason === "not in allowlist") && isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id) ) { return { enabled: true }; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 6277a7d1582..dfb1098ca68 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1014,6 +1014,22 @@ describe("loadOpenClawPlugins", () => { expectTelegramLoaded(registry); }, }, + { + name: "loads bundled channel plugins when channels..enabled=true even under restrictive plugins.allow", + config: { + channels: { + telegram: { + enabled: true, + }, + }, + plugins: { + allow: ["browser"], + }, + } satisfies PluginLoadConfig, + assert: (registry: ReturnType) => { + expectTelegramLoaded(registry); + }, + }, { name: "still respects explicit disable via plugins.entries for bundled channels", config: {