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..05a85228550 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -471,6 +471,27 @@ describe("config plugin validation", () => { expect(res.ok).toBe(true); }); + it("accepts built-in hook mapping channels without local channel config", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + hooks: { + mappings: [{ channel: "discord" }], + }, + }); + expect(res.ok).toBe(true); + }); + + it("accepts discovered plugin hook mapping channels without channel config", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + hooks: { + mappings: [{ channel: "bluebubbles" }], + }, + plugins: { enabled: false, load: { paths: [bluebubblesPluginDir] } }, + }); + expect(res.ok).toBe(true); + }); + 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..8db22418c5f 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -496,6 +496,54 @@ function validateConfigObjectWithPluginsBase( } } + const allowedHookChannels = new Set(["last"]); + for (const channelId of CHANNEL_IDS) { + allowedHookChannels.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); + } + } + } + + 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 (normalized === "last") { + return; + } + if (!allowedHookChannels.has(normalized)) { + 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(),