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:
committed by
Peter Steinberger
parent
3f07d725b1
commit
203de14211
@@ -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({
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user