diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index f3ef2961f..943dbe346 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -1,7 +1,27 @@ import { describe, expect, it } from "vitest"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateConfigObject } from "./config.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; +/** Helper to build a minimal PluginManifestRegistry for testing. */ +function makeRegistry( + plugins: Array<{ id: string; channels: string[] }>, +): PluginManifestRegistry { + return { + plugins: plugins.map((p) => ({ + id: p.id, + channels: p.channels, + providers: [], + skills: [], + origin: "config" as const, + rootDir: `/fake/${p.id}`, + source: `/fake/${p.id}/index.js`, + manifestPath: `/fake/${p.id}/openclaw.plugin.json`, + })), + diagnostics: [], + }; +} + describe("applyPluginAutoEnable", () => { it("auto-enables built-in channels and appends to existing allowlist", () => { const result = applyPluginAutoEnable({ @@ -136,6 +156,65 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + describe("third-party channel plugins (pluginId ≠ channelId)", () => { + it("uses the plugin manifest id, not the channel id, for plugins.entries", () => { + // Reproduces: https://github.com/openclaw/openclaw/issues/25261 + // Plugin "apn-channel" declares channels: ["apn"]. Doctor must write + // plugins.entries["apn-channel"], not plugins.entries["apn"]. + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["apn"]).toBeUndefined(); + expect(result.changes.join("\n")).toContain("apn configured, enabled automatically."); + }); + + it("does not double-enable when plugin is already enabled under its plugin id", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + plugins: { entries: { "apn-channel": { enabled: true } } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.changes).toEqual([]); + }); + + it("respects explicit disable of the plugin by its plugin id", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { apn: { someKey: "value" } }, + plugins: { entries: { "apn-channel": { enabled: false } } }, + }, + env: {}, + manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]), + }); + + expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false); + expect(result.changes).toEqual([]); + }); + + it("falls back to channel key as plugin id when no installed manifest declares the channel", () => { + // Without a matching manifest entry, behavior is unchanged (backward compat). + const result = applyPluginAutoEnable({ + config: { + channels: { "unknown-chan": { someKey: "value" } }, + }, + env: {}, + manifestRegistry: makeRegistry([]), + }); + + expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true); + }); + }); + describe("preferOver channel prioritization", () => { it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => { const result = applyPluginAutoEnable({ diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 63657e3ea..434650c17 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -8,6 +8,10 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRegistry, +} from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; import { hasAnyWhatsAppAuth } from "../web/accounts.js"; import type { OpenClawConfig } from "./config.js"; @@ -309,32 +313,74 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean return false; } +function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map { + const map = new Map(); + for (const record of registry.plugins) { + for (const channelId of record.channels) { + if (channelId && !map.has(channelId)) { + map.set(channelId, record.id); + } + } + } + return map; +} + +type ChannelPluginPair = { + channelId: string; + pluginId: string; +}; + function resolveConfiguredPlugins( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, + registry: PluginManifestRegistry, ): PluginEnableChange[] { const changes: PluginEnableChange[] = []; - const channelIds = new Set(CHANNEL_PLUGIN_IDS); + // Build reverse map: channel ID → plugin ID from installed plugin manifests. + // This is needed when a third-party plugin declares a channel ID that differs + // from the plugin's own ID (e.g. plugin id="apn-channel", channels=["apn"]). + const channelToPluginId = buildChannelToPluginIdMap(registry); + + // For built-in and catalog entries: channelId === pluginId (they are the same). + const pairs: ChannelPluginPair[] = CHANNEL_PLUGIN_IDS.map((id) => ({ + channelId: id, + pluginId: id, + })); + const configuredChannels = cfg.channels as Record | undefined; if (configuredChannels && typeof configuredChannels === "object") { for (const key of Object.keys(configuredChannels)) { if (key === "defaults" || key === "modelByChannel") { continue; } - channelIds.add(normalizeChatChannelId(key) ?? key); + const builtInId = normalizeChatChannelId(key); + if (builtInId) { + // Built-in channel: channelId and pluginId are the same. + pairs.push({ channelId: builtInId, pluginId: builtInId }); + } else { + // Third-party channel plugin: look up the actual plugin ID from the + // manifest registry. If the plugin declares channels=["apn"] but its + // id is "apn-channel", we must use "apn-channel" as the pluginId so + // that plugins.entries is keyed correctly. Fall back to the channel key + // when no installed manifest declares this channel. + const pluginId = channelToPluginId.get(key) ?? key; + pairs.push({ channelId: key, pluginId }); + } } } - for (const channelId of channelIds) { - if (!channelId) { + + // Deduplicate by channelId, preserving first occurrence. + const seenChannelIds = new Set(); + for (const { channelId, pluginId } of pairs) { + if (!channelId || !pluginId || seenChannelIds.has(channelId)) { continue; } + seenChannelIds.add(channelId); if (isChannelConfigured(cfg, channelId, env)) { - changes.push({ - pluginId: channelId, - reason: `${channelId} configured`, - }); + changes.push({ pluginId, reason: `${channelId} configured` }); } } + for (const mapping of PROVIDER_PLUGIN_IDS) { if (isProviderConfigured(cfg, mapping.providerId)) { changes.push({ @@ -450,9 +496,15 @@ function formatAutoEnableChange(entry: PluginEnableChange): string { export function applyPluginAutoEnable(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; + /** Pre-loaded manifest registry. When omitted, the registry is loaded from + * the installed plugins on disk. Pass an explicit registry in tests to + * avoid filesystem access and control what plugins are "installed". */ + manifestRegistry?: PluginManifestRegistry; }): PluginAutoEnableResult { const env = params.env ?? process.env; - const configured = resolveConfiguredPlugins(params.config, env); + const registry = + params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config }); + const configured = resolveConfiguredPlugins(params.config, env, registry); if (configured.length === 0) { return { config: params.config, changes: [] }; }