fix(doctor): use plugin manifest id for third-party channel auto-enable

When a third-party channel plugin declares a channel ID that differs from
its plugin ID (e.g. plugin id="apn-channel", channels=["apn"]), the
doctor plugin auto-enable logic was using the channel ID ("apn") as the
key for plugins.entries, producing an entry that fails config validation:
  Error: plugins.entries.apn: plugin not found: apn

Root cause: resolveConfiguredPlugins iterated over cfg.channels keys and
used each key directly as both the channel ID (for isChannelConfigured)
and the plugin ID (for plugins.entries). For built-in channels these are
always the same, but for third-party plugins they can differ.

Fix: load the installed plugin manifest registry and build a reverse map
from channel ID to plugin ID. When a cfg.channels key does not resolve to
a built-in channel, look up the declaring plugin's manifest ID and use
that as the pluginId in the PluginEnableChange, so registerPluginEntry
writes the correct plugins.entries["apn-channel"] key.

The applyPluginAutoEnable function now accepts an optional manifestRegistry
parameter for testing, avoiding filesystem access in unit tests.

Fixes #25261

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zerone0x
2026-02-24 11:09:31 +01:00
committed by Peter Steinberger
parent 3f07d725b1
commit 203de14211
2 changed files with 140 additions and 9 deletions

View File

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

View File

@@ -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<string, string> {
const map = new Map<string, string>();
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<string, unknown> | 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<string>();
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: [] };
}