Config: validate dynamic hook mapping channels

This commit is contained in:
Weston Johnson 2026-03-19 21:18:46 -06:00
parent d78e13f545
commit 0bf51e4dcb
6 changed files with 99 additions and 27 deletions

View File

@ -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 },

View File

@ -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 [];
}

View File

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

View File

@ -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";

View File

@ -496,6 +496,54 @@ function validateConfigObjectWithPluginsBase(
}
}
const allowedHookChannels = new Set<string>(["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 };

View File

@ -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(),