mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 22:08:26 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user