feat(hooks): add agentId support to webhook mappings (#13672)

* feat(hooks): add agentId support to webhook mappings

Allow webhook mappings to route hook runs to a specific agent via
the new `agentId` field. This enables lightweight agents with minimal
bootstrap files to handle webhooks, reducing token cost per hook run.

The agentId is threaded through:
- HookMappingConfig (config type + zod schema)
- HookMappingResolved + HookAction (mapping types)
- normalizeHookMapping + buildActionFromMapping (mapping logic)
- mergeAction (transform override support)
- HookAgentPayload + normalizeAgentPayload (direct /hooks/agent endpoint)
- dispatchAgentHook → CronJob.agentId (server dispatch)

The existing runCronIsolatedAgentTurn already supports agentId on
CronJob — this change simply wires it through from webhook mappings.

Usage in config:
  hooks.mappings[].agentId = "my-agent"

Usage via POST /hooks/agent:
  { "message": "...", "agentId": "my-agent" }

Includes tests for mapping passthrough and payload normalization.
Includes doc updates for webhook.md.

* fix(hooks): enforce webhook agent routing policy + docs/changelog updates (#13672) (thanks @BillChirico)

* fix(hooks): harden explicit agent allowlist semantics (#13672) (thanks @BillChirico)

---------

Co-authored-by: Pip <pip@openclaw.ai>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
This commit is contained in:
Bill Chirico
2026-02-10 19:23:58 -05:00
committed by GitHub
parent 45488e4ec9
commit ca629296c6
13 changed files with 448 additions and 2 deletions

View File

@@ -2,7 +2,9 @@ import type { IncomingMessage } from "node:http";
import { randomUUID } from "node:crypto";
import type { ChannelId } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js";
@@ -14,6 +16,13 @@ export type HooksConfigResolved = {
token: string;
maxBodyBytes: number;
mappings: HookMappingResolved[];
agentPolicy: HookAgentPolicyResolved;
};
export type HookAgentPolicyResolved = {
defaultAgentId: string;
knownAgentIds: Set<string>;
allowedAgentIds?: Set<string>;
};
export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | null {
@@ -35,14 +44,51 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n
? cfg.hooks.maxBodyBytes
: DEFAULT_HOOKS_MAX_BODY_BYTES;
const mappings = resolveHookMappings(cfg.hooks);
const defaultAgentId = resolveDefaultAgentId(cfg);
const knownAgentIds = resolveKnownAgentIds(cfg, defaultAgentId);
const allowedAgentIds = resolveAllowedAgentIds(cfg.hooks?.allowedAgentIds);
return {
basePath: trimmed,
token,
maxBodyBytes,
mappings,
agentPolicy: {
defaultAgentId,
knownAgentIds,
allowedAgentIds,
},
};
}
function resolveKnownAgentIds(cfg: OpenClawConfig, defaultAgentId: string): Set<string> {
const known = new Set(listAgentIds(cfg));
known.add(defaultAgentId);
return known;
}
function resolveAllowedAgentIds(raw: string[] | undefined): Set<string> | undefined {
if (!Array.isArray(raw)) {
return undefined;
}
const allowed = new Set<string>();
let hasWildcard = false;
for (const entry of raw) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
if (trimmed === "*") {
hasWildcard = true;
break;
}
allowed.add(normalizeAgentId(trimmed));
}
if (hasWildcard) {
return undefined;
}
return allowed;
}
export function extractHookToken(req: IncomingMessage): string | undefined {
const auth =
typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : "";
@@ -138,6 +184,7 @@ export function normalizeWakePayload(
export type HookAgentPayload = {
message: string;
name: string;
agentId?: string;
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
@@ -173,6 +220,40 @@ export function resolveHookDeliver(raw: unknown): boolean {
return raw !== false;
}
export function resolveHookTargetAgentId(
hooksConfig: HooksConfigResolved,
agentId: string | undefined,
): string | undefined {
const raw = agentId?.trim();
if (!raw) {
return undefined;
}
const normalized = normalizeAgentId(raw);
if (hooksConfig.agentPolicy.knownAgentIds.has(normalized)) {
return normalized;
}
return hooksConfig.agentPolicy.defaultAgentId;
}
export function isHookAgentAllowed(
hooksConfig: HooksConfigResolved,
agentId: string | undefined,
): boolean {
// Keep backwards compatibility for callers that omit agentId.
const raw = agentId?.trim();
if (!raw) {
return true;
}
const allowed = hooksConfig.agentPolicy.allowedAgentIds;
if (allowed === undefined) {
return true;
}
const resolved = resolveHookTargetAgentId(hooksConfig, raw);
return resolved ? allowed.has(resolved) : false;
}
export const getHookAgentPolicyError = () => "agentId is not allowed by hooks.allowedAgentIds";
export function normalizeAgentPayload(
payload: Record<string, unknown>,
opts?: { idFactory?: () => string },
@@ -188,6 +269,9 @@ export function normalizeAgentPayload(
}
const nameRaw = payload.name;
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
const agentIdRaw = payload.agentId;
const agentId =
typeof agentIdRaw === "string" && agentIdRaw.trim() ? agentIdRaw.trim() : undefined;
const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
const sessionKeyRaw = payload.sessionKey;
const idFactory = opts?.idFactory ?? randomUUID;
@@ -220,6 +304,7 @@ export function normalizeAgentPayload(
value: {
message,
name,
agentId,
wakeMode,
sessionKey,
deliver,