diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 6e9cc07bf7e..c5eb8e4e237 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -613,6 +613,29 @@ describe("config cli", () => { ); }); + it("rejects invalid hook mapping channels in JSON dry-run mode", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "hooks.mappings[0].channel", + '"not-a-real-channel"', + "--strict-json", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("unknown hook mapping channel: not-a-real-channel"), + ); + }); + it("logs a dry-run note when value mode performs no validation checks", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 }, diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index e7a94ae99ab..349b967ccf8 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -15,7 +15,7 @@ import { type SecretRef, type SecretRefSource, } from "../config/types.secrets.js"; -import { validateConfigObjectRaw } from "../config/validation.js"; +import { validateConfigObjectRawWithPlugins } from "../config/validation.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; import { danger, info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -920,7 +920,7 @@ function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInD } function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { - const validated = validateConfigObjectRaw(config); + const validated = validateConfigObjectRawWithPlugins(config); if (validated.ok) { return []; } diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 42d473aed4e..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 () => { @@ -471,6 +484,57 @@ describe("config plugin validation", () => { expect(res.ok).toBe(true); }); + 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: "bluebubbles" }], + }, + plugins: { + load: { paths: [bluebubblesPluginDir] }, + entries: { "bluebubbles-plugin": { enabled: true } }, + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects disabled plugin hook mapping channels", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + hooks: { + mappings: [{ channel: "bluebubbles" }], + }, + plugins: { enabled: false, load: { paths: [bluebubblesPluginDir] } }, + }); + 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 () => { const res = validateInSuite({ agents: { diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 3c5f7a74f0e..19cd4461798 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -1,3 +1,6 @@ +import type { ChannelId } from "../channels/plugins/types.js"; +import type { InstallRecordBase } from "./types.installs.js"; + export type HookMappingMatch = { path?: string; source?: string; @@ -22,17 +25,7 @@ export type HookMappingConfig = { deliver?: boolean; /** DANGEROUS: Disable external content safety wrapping for this hook. */ allowUnsafeExternalContent?: boolean; - channel?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "irc" - | "googlechat" - | "slack" - | "signal" - | "imessage" - | "msteams"; + channel?: ChannelId | "last"; to?: string; /** Override model for this hook (provider/model or alias). */ model?: string; @@ -139,4 +132,3 @@ export type HooksConfig = { /** Internal agent event hooks */ internal?: InternalHooksConfig; }; -import type { InstallRecordBase } from "./types.installs.js"; diff --git a/src/config/validation.ts b/src/config/validation.ts index 98a1fd29fc6..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,6 +495,76 @@ function validateConfigObjectWithPluginsBase( } } + const builtInHookChannels = new Set(["last"]); + for (const channelId of CHANNEL_IDS) { + builtInHookChannels.add(channelId.toLowerCase()); + } + 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) { + return; + } + if (typeof channel !== "string") { + issues.push({ path, message: "hooks.mappings[].channel must be a string" }); + return; + } + const trimmed = channel.trim(); + if (!trimmed) { + issues.push({ path, message: "hooks.mappings[].channel must not be empty" }); + return; + } + const normalized = normalizeChatChannelId(trimmed) ?? trimmed.toLowerCase(); + if (builtInHookChannels.has(normalized)) { + return; + } + if (ensureEnabledHookPluginChannels().has(normalized)) { + return; + } + issues.push({ path, message: `unknown hook mapping channel: ${trimmed}` }); + }; + + if (Array.isArray(config.hooks?.mappings)) { + for (const [index, mapping] of config.hooks.mappings.entries()) { + if (!mapping || typeof mapping !== "object") { + continue; + } + validateHookMappingChannel( + (mapping as { channel?: unknown }).channel, + `hooks.mappings.${index}.channel`, + ); + } + } + if (!hasExplicitPluginsConfig) { if (issues.length > 0) { return { ok: false, issues, warnings }; diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 57d617bbd6b..989cc9c8d06 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -49,19 +49,7 @@ export const HookMappingSchema = z textTemplate: z.string().optional(), deliver: z.boolean().optional(), allowUnsafeExternalContent: z.boolean().optional(), - channel: z - .union([ - z.literal("last"), - z.literal("whatsapp"), - z.literal("telegram"), - z.literal("discord"), - z.literal("irc"), - z.literal("slack"), - z.literal("signal"), - z.literal("imessage"), - z.literal("msteams"), - ]) - .optional(), + channel: z.string().optional(), to: z.string().optional(), model: z.string().optional(), thinking: z.string().optional(),