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)
This commit is contained in:
Ayaan Zaidi 2026-04-01 14:42:36 +05:30 committed by GitHub
parent 2d53ffdec1
commit fb28b02540
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 273 additions and 2 deletions

View File

@ -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.<id>` 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

View File

@ -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: {

View File

@ -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}.`);
});
}

View File

@ -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");
});
});

View File

@ -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"));
}

View File

@ -158,8 +158,25 @@ describe("resolveEffectiveEnableState", () => {
});
}
function resolveConfigOriginTelegramState(config: Parameters<typeof normalizePluginsConfig>[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", () => {

View File

@ -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 };

View File

@ -1014,6 +1014,22 @@ describe("loadOpenClawPlugins", () => {
expectTelegramLoaded(registry);
},
},
{
name: "loads bundled channel plugins when channels.<id>.enabled=true even under restrictive plugins.allow",
config: {
channels: {
telegram: {
enabled: true,
},
},
plugins: {
allow: ["browser"],
},
} satisfies PluginLoadConfig,
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
expectTelegramLoaded(registry);
},
},
{
name: "still respects explicit disable via plugins.entries for bundled channels",
config: {