diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 05a85228550..49a9cd56dd9 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { validateConfigObjectWithPlugins } from "./config.js"; @@ -43,6 +44,16 @@ async function writePluginFixture(params: { ); } +async function writeMalformedPluginFixture(params: { dir: string }) { + await mkdirSafe(params.dir); + await fs.writeFile( + path.join(params.dir, "index.js"), + 'export default { id: "broken-plugin", register() {} };\n', + "utf-8", + ); + await fs.writeFile(path.join(params.dir, "openclaw.plugin.json"), "{", "utf-8"); +} + async function writeBundleFixture(params: { dir: string; format: "codex" | "claude"; @@ -163,6 +174,7 @@ describe("config plugin validation", () => { schema: voiceCallManifest.configSchema, }); clearPluginManifestRegistryCache(); + clearPluginDiscoveryCache(); // Warm the plugin manifest cache once so path-based validations can reuse // parsed manifests across test cases. validateInSuite({ @@ -184,6 +196,7 @@ describe("config plugin validation", () => { afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); clearPluginManifestRegistryCache(); + clearPluginDiscoveryCache(); }); it("reports missing plugin refs across load paths, entries, and allowlist surfaces", async () => { @@ -472,16 +485,40 @@ describe("config plugin validation", () => { }); it("accepts built-in hook mapping channels without local channel config", async () => { + const malformedAutoPluginDir = path.join(suiteHome, ".openclaw", "extensions", "broken-plugin"); + await writeMalformedPluginFixture({ dir: malformedAutoPluginDir }); + clearPluginManifestRegistryCache(); + clearPluginDiscoveryCache(); + try { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + hooks: { + mappings: [{ channel: "discord" }], + }, + }); + expect(res.ok).toBe(true); + } finally { + await fs.rm(malformedAutoPluginDir, { recursive: true, force: true }); + clearPluginManifestRegistryCache(); + clearPluginDiscoveryCache(); + } + }); + + it("accepts enabled plugin hook mapping channels without channel config", async () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, hooks: { - mappings: [{ channel: "discord" }], + mappings: [{ channel: "bluebubbles" }], + }, + plugins: { + load: { paths: [bluebubblesPluginDir] }, + entries: { "bluebubbles-plugin": { enabled: true } }, }, }); expect(res.ok).toBe(true); }); - it("accepts discovered plugin hook mapping channels without channel config", async () => { + it("rejects disabled plugin hook mapping channels", async () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, hooks: { @@ -489,7 +526,13 @@ describe("config plugin validation", () => { }, plugins: { enabled: false, load: { paths: [bluebubblesPluginDir] } }, }); - expect(res.ok).toBe(true); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues).toContainEqual({ + path: "hooks.mappings.0.channel", + message: "unknown hook mapping channel: bluebubbles", + }); + } }); it("rejects unknown heartbeat targets", async () => { diff --git a/src/config/validation.ts b/src/config/validation.ts index 8db22418c5f..79ae8fdbc84 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -349,11 +349,11 @@ function validateConfigObjectWithPluginsBase( type RegistryInfo = { registry: ReturnType; knownIds?: Set; - normalizedPlugins?: ReturnType; }; let registryInfo: RegistryInfo | null = null; let compatConfig: OpenClawConfig | null | undefined; + let memoizedNormalizedPlugins: ReturnType | null = null; const ensureCompatConfig = (): OpenClawConfig => { if (compatConfig !== undefined) { @@ -416,11 +416,10 @@ function validateConfigObjectWithPluginsBase( }; const ensureNormalizedPlugins = (): ReturnType => { - const info = ensureRegistry(); - if (!info.normalizedPlugins) { - info.normalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins); + if (!memoizedNormalizedPlugins) { + memoizedNormalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins); } - return info.normalizedPlugins; + return memoizedNormalizedPlugins; }; const allowedChannels = new Set(["defaults", "modelByChannel", ...CHANNEL_IDS]); @@ -496,19 +495,40 @@ function validateConfigObjectWithPluginsBase( } } - const allowedHookChannels = new Set(["last"]); + const builtInHookChannels = new Set(["last"]); for (const channelId of CHANNEL_IDS) { - allowedHookChannels.add(channelId.toLowerCase()); + builtInHookChannels.add(channelId.toLowerCase()); } - const { registry: hookChannelRegistry } = ensureRegistry(); - for (const record of hookChannelRegistry.plugins) { - for (const channelId of record.channels) { - const normalized = channelId.trim().toLowerCase(); - if (normalized) { - allowedHookChannels.add(normalized); + let enabledHookPluginChannels: Set | null = null; + + const ensureEnabledHookPluginChannels = (): Set => { + if (enabledHookPluginChannels) { + return enabledHookPluginChannels; + } + + enabledHookPluginChannels = new Set(); + const normalizedPlugins = ensureNormalizedPlugins(); + const { registry: hookChannelRegistry } = ensureRegistry(); + for (const record of hookChannelRegistry.plugins) { + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: config, + enabledByDefault: record.enabledByDefault, + }); + if (!enableState.enabled) { + continue; + } + for (const channelId of record.channels) { + const normalized = channelId.trim().toLowerCase(); + if (normalized) { + enabledHookPluginChannels.add(normalized); + } } } - } + return enabledHookPluginChannels; + }; const validateHookMappingChannel = (channel: unknown, path: string) => { if (channel === undefined) { @@ -524,12 +544,13 @@ function validateConfigObjectWithPluginsBase( return; } const normalized = normalizeChatChannelId(trimmed) ?? trimmed.toLowerCase(); - if (normalized === "last") { + if (builtInHookChannels.has(normalized)) { return; } - if (!allowedHookChannels.has(normalized)) { - issues.push({ path, message: `unknown hook mapping channel: ${trimmed}` }); + if (ensureEnabledHookPluginChannels().has(normalized)) { + return; } + issues.push({ path, message: `unknown hook mapping channel: ${trimmed}` }); }; if (Array.isArray(config.hooks?.mappings)) {