From ca629296c668511016241099dc4be9b0373fe8ca Mon Sep 17 00:00:00 2001 From: Bill Chirico Date: Tue, 10 Feb 2026 19:23:58 -0500 Subject: [PATCH] feat(hooks): add agentId support to webhook mappings (#13672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Gustavo Madeira Santana --- CHANGELOG.md | 1 + docs/automation/webhook.md | 9 ++ docs/gateway/configuration.md | 9 +- src/config/types.hooks.ts | 7 ++ src/config/zod-schema.hooks.ts | 1 + src/config/zod-schema.ts | 1 + src/gateway/hooks-mapping.test.ts | 47 ++++++++ src/gateway/hooks-mapping.ts | 6 + src/gateway/hooks.test.ts | 99 ++++++++++++++++ src/gateway/hooks.ts | 85 ++++++++++++++ src/gateway/server-http.ts | 18 ++- src/gateway/server.hooks.e2e.test.ts | 165 +++++++++++++++++++++++++++ src/gateway/server/hooks.ts | 2 + 13 files changed, 448 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 223e13a1628..abe4a9bbf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Agents: auto-select `zai/glm-4.6v` for image understanding when ZAI is primary provider. (#10267) Thanks @liuy. - Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight. - Onboarding: add Custom Provider flow for OpenAI and Anthropic-compatible endpoints. (#11106) Thanks @MackDing. +- Hooks: route webhook agent runs to specific `agentId`s, add `hooks.allowedAgentIds` controls, and fall back to default agent when unknown IDs are provided. (#13672) Thanks @BillChirico. ### Fixes diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 93a474b32e1..78fb7d63789 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -18,6 +18,10 @@ Gateway can expose a small HTTP webhook endpoint for external triggers. enabled: true, token: "shared-secret", path: "/hooks", + // Optional: restrict explicit `agentId` routing to this allowlist. + // Omit or include "*" to allow any agent. + // Set [] to deny all explicit `agentId` routing. + allowedAgentIds: ["hooks", "main"], }, } ``` @@ -61,6 +65,7 @@ Payload: { "message": "Run this", "name": "Email", + "agentId": "hooks", "sessionKey": "hook:email:msg-123", "wakeMode": "now", "deliver": true, @@ -74,6 +79,7 @@ Payload: - `message` **required** (string): The prompt or message for the agent to process. - `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries. +- `agentId` optional (string): Route this hook to a specific agent. Unknown IDs fall back to the default agent. When set, the hook runs using the resolved agent's workspace and configuration. - `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:`. Using a consistent key allows for a multi-turn conversation within the hook context. - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. @@ -104,6 +110,8 @@ Mapping options (summary): - TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime. - Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface (`channel` defaults to `last` and falls back to WhatsApp). +- `agentId` routes the hook to a specific agent; unknown IDs fall back to the default agent. +- `hooks.allowedAgentIds` restricts explicit `agentId` routing. Omit it (or include `*`) to allow any agent. Set `[]` to deny explicit `agentId` routing. - `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook (dangerous; only for trusted internal sources). - `openclaw webhooks gmail setup` writes `hooks.gmail` config for `openclaw webhooks gmail run`. @@ -157,6 +165,7 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \ - Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. - Use a dedicated hook token; do not reuse gateway auth tokens. +- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection. - Avoid including sensitive raw payloads in webhook logs. - Hook payloads are treated as untrusted and wrapped with safety boundaries by default. If you must disable this for a specific hook, set `allowUnsafeExternalContent: true` diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index c333525a5e4..bdb3b1ed729 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -3176,12 +3176,17 @@ Defaults: enabled: true, token: "shared-secret", path: "/hooks", + // Optional: restrict explicit `agentId` routing. + // Omit or include "*" to allow any agent. + // Set [] to deny all explicit `agentId` routing. + allowedAgentIds: ["hooks", "main"], presets: ["gmail"], transformsDir: "~/.openclaw/hooks", mappings: [ { match: { path: "gmail" }, action: "agent", + agentId: "hooks", wakeMode: "now", name: "Gmail", sessionKey: "hook:gmail:{{messages[0].id}}", @@ -3203,7 +3208,7 @@ Requests must include the hook token: Endpoints: - `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }` -- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }` +- `POST /hooks/agent` → `{ message, name?, agentId?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }` - `POST /hooks/` → resolved via `hooks.mappings` `/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`). @@ -3214,6 +3219,8 @@ Mapping notes: - `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path. - Templates like `{{messages[0].subject}}` read from the payload. - `transform` can point to a JS/TS module that returns a hook action. +- `agentId` can route to a specific agent; unknown IDs fall back to the default agent. +- `hooks.allowedAgentIds` restricts explicit `agentId` routing (`*` or omitted means allow all, `[]` denies all explicit routing). - `deliver: true` sends the final reply to a channel; `channel` defaults to `last` (falls back to WhatsApp). - If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Google Chat/Slack/Signal/iMessage/MS Teams). - `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set). diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 52dd57ce36e..86ecdd60abe 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -14,6 +14,8 @@ export type HookMappingConfig = { action?: "wake" | "agent"; wakeMode?: "now" | "next-heartbeat"; name?: string; + /** Route this hook to a specific agent (unknown ids fall back to the default agent). */ + agentId?: string; sessionKey?: string; messageTemplate?: string; textTemplate?: string; @@ -115,6 +117,11 @@ export type HooksConfig = { enabled?: boolean; path?: string; token?: string; + /** + * Restrict explicit hook `agentId` routing to these agent ids. + * Omit or include `*` to allow any agent. Set `[]` to deny all explicit `agentId` routing. + */ + allowedAgentIds?: string[]; maxBodyBytes?: number; presets?: string[]; transformsDir?: string; diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 471e422d32e..3130f8cb9e3 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -12,6 +12,7 @@ export const HookMappingSchema = z action: z.union([z.literal("wake"), z.literal("agent")]).optional(), wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(), name: z.string().optional(), + agentId: z.string().optional(), sessionKey: z.string().optional(), messageTemplate: z.string().optional(), textTemplate: z.string().optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 72396ddd3f0..604a6ea3157 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -301,6 +301,7 @@ export const OpenClawSchema = z enabled: z.boolean().optional(), path: z.string().optional(), token: z.string().optional(), + allowedAgentIds: z.array(z.string()).optional(), maxBodyBytes: z.number().int().positive().optional(), presets: z.array(z.string()).optional(), transformsDir: z.string().optional(), diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index d7b9924ed46..3666b850f94 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -152,6 +152,53 @@ describe("hooks mapping", () => { } }); + it("passes agentId from mapping", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "hooks-agent", + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + agentId: "hooks", + }, + ], + }); + const result = await applyHookMappings(mappings, { + payload: { messages: [{ subject: "Hello" }] }, + headers: {}, + url: baseUrl, + path: "gmail", + }); + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.agentId).toBe("hooks"); + } + }); + + it("agentId is undefined when not set", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "no-agent", + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + }, + ], + }); + const result = await applyHookMappings(mappings, { + payload: { messages: [{ subject: "Hello" }] }, + headers: {}, + url: baseUrl, + path: "gmail", + }); + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.agentId).toBeUndefined(); + } + }); + it("rejects missing message", async () => { const mappings = resolveHookMappings({ mappings: [{ match: { path: "noop" }, action: "agent" }], diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index abcea54f673..f3e3ccb62a6 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -10,6 +10,7 @@ export type HookMappingResolved = { action: "wake" | "agent"; wakeMode?: "now" | "next-heartbeat"; name?: string; + agentId?: string; sessionKey?: string; messageTemplate?: string; textTemplate?: string; @@ -45,6 +46,7 @@ export type HookAction = kind: "agent"; message: string; name?: string; + agentId?: string; wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; @@ -83,6 +85,7 @@ type HookTransformResult = Partial<{ text: string; mode: "now" | "next-heartbeat"; message: string; + agentId: string; wakeMode: "now" | "next-heartbeat"; name: string; sessionKey: string; @@ -196,6 +199,7 @@ function normalizeHookMapping( action, wakeMode, name: mapping.name, + agentId: mapping.agentId?.trim() || undefined, sessionKey: mapping.sessionKey, messageTemplate: mapping.messageTemplate, textTemplate: mapping.textTemplate, @@ -247,6 +251,7 @@ function buildActionFromMapping( kind: "agent", message, name: renderOptional(mapping.name, ctx), + agentId: mapping.agentId, wakeMode: mapping.wakeMode ?? "now", sessionKey: renderOptional(mapping.sessionKey, ctx), deliver: mapping.deliver, @@ -285,6 +290,7 @@ function mergeAction( message, wakeMode, name: override.name ?? baseAgent?.name, + agentId: override.agentId ?? baseAgent?.agentId, sessionKey: override.sessionKey ?? baseAgent?.sessionKey, deliver: typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver, allowUnsafeExternalContent: diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 811911221e8..62cf41a52c6 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -6,6 +6,8 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createIMessageTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { extractHookToken, + isHookAgentAllowed, + resolveHookTargetAgentId, normalizeAgentPayload, normalizeWakePayload, resolveHooksConfig, @@ -126,6 +128,103 @@ describe("gateway hooks helpers", () => { const bad = normalizeAgentPayload({ message: "yo", channel: "sms" }); expect(bad.ok).toBe(false); }); + + test("normalizeAgentPayload passes agentId", () => { + const ok = normalizeAgentPayload( + { message: "hello", agentId: "hooks" }, + { idFactory: () => "fixed" }, + ); + expect(ok.ok).toBe(true); + if (ok.ok) { + expect(ok.value.agentId).toBe("hooks"); + } + + const noAgent = normalizeAgentPayload({ message: "hello" }, { idFactory: () => "fixed" }); + expect(noAgent.ok).toBe(true); + if (noAgent.ok) { + expect(noAgent.value.agentId).toBeUndefined(); + } + }); + + test("resolveHookTargetAgentId falls back to default for unknown agent ids", () => { + const cfg = { + hooks: { enabled: true, token: "secret" }, + agents: { + list: [{ id: "main", default: true }, { id: "hooks" }], + }, + } as OpenClawConfig; + const resolved = resolveHooksConfig(cfg); + expect(resolved).not.toBeNull(); + if (!resolved) { + return; + } + expect(resolveHookTargetAgentId(resolved, "hooks")).toBe("hooks"); + expect(resolveHookTargetAgentId(resolved, "missing-agent")).toBe("main"); + expect(resolveHookTargetAgentId(resolved, undefined)).toBeUndefined(); + }); + + test("isHookAgentAllowed honors hooks.allowedAgentIds for explicit routing", () => { + const cfg = { + hooks: { + enabled: true, + token: "secret", + allowedAgentIds: ["hooks"], + }, + agents: { + list: [{ id: "main", default: true }, { id: "hooks" }], + }, + } as OpenClawConfig; + const resolved = resolveHooksConfig(cfg); + expect(resolved).not.toBeNull(); + if (!resolved) { + return; + } + expect(isHookAgentAllowed(resolved, undefined)).toBe(true); + expect(isHookAgentAllowed(resolved, "hooks")).toBe(true); + expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(false); + }); + + test("isHookAgentAllowed treats empty allowlist as deny-all for explicit agentId", () => { + const cfg = { + hooks: { + enabled: true, + token: "secret", + allowedAgentIds: [], + }, + agents: { + list: [{ id: "main", default: true }, { id: "hooks" }], + }, + } as OpenClawConfig; + const resolved = resolveHooksConfig(cfg); + expect(resolved).not.toBeNull(); + if (!resolved) { + return; + } + expect(isHookAgentAllowed(resolved, undefined)).toBe(true); + expect(isHookAgentAllowed(resolved, "hooks")).toBe(false); + expect(isHookAgentAllowed(resolved, "main")).toBe(false); + }); + + test("isHookAgentAllowed treats wildcard allowlist as allow-all", () => { + const cfg = { + hooks: { + enabled: true, + token: "secret", + allowedAgentIds: ["*"], + }, + agents: { + list: [{ id: "main", default: true }, { id: "hooks" }], + }, + } as OpenClawConfig; + const resolved = resolveHooksConfig(cfg); + expect(resolved).not.toBeNull(); + if (!resolved) { + return; + } + expect(isHookAgentAllowed(resolved, undefined)).toBe(true); + expect(isHookAgentAllowed(resolved, "hooks")).toBe(true); + expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(true); + }); }); const emptyRegistry = createTestRegistry([]); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index fe79f0f383c..ff8886585e3 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -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; + allowedAgentIds?: Set; }; 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 { + const known = new Set(listAgentIds(cfg)); + known.add(defaultAgentId); + return known; +} + +function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { + if (!Array.isArray(raw)) { + return undefined; + } + const allowed = new Set(); + 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, 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, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 66a6f725ab2..d3f0cc24618 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -28,13 +28,16 @@ import { import { applyHookMappings } from "./hooks-mapping.js"; import { extractHookToken, + getHookAgentPolicyError, getHookChannelError, type HookMessageChannel, type HooksConfigResolved, + isHookAgentAllowed, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody, + resolveHookTargetAgentId, resolveHookChannel, resolveHookDeliver, } from "./hooks.js"; @@ -52,6 +55,7 @@ type HookDispatchers = { dispatchAgentHook: (value: { message: string; name: string; + agentId?: string; wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; @@ -207,7 +211,14 @@ export function createHooksRequestHandler( sendJson(res, 400, { ok: false, error: normalized.error }); return true; } - const runId = dispatchAgentHook(normalized.value); + if (!isHookAgentAllowed(hooksConfig, normalized.value.agentId)) { + sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() }); + return true; + } + const runId = dispatchAgentHook({ + ...normalized.value, + agentId: resolveHookTargetAgentId(hooksConfig, normalized.value.agentId), + }); sendJson(res, 202, { ok: true, runId }); return true; } @@ -243,9 +254,14 @@ export function createHooksRequestHandler( sendJson(res, 400, { ok: false, error: getHookChannelError() }); return true; } + if (!isHookAgentAllowed(hooksConfig, mapped.action.agentId)) { + sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() }); + return true; + } const runId = dispatchAgentHook({ message: mapped.action.message, name: mapped.action.name ?? "Hook", + agentId: resolveHookTargetAgentId(hooksConfig, mapped.action.agentId), wakeMode: mapped.action.wakeMode, sessionKey: mapped.action.sessionKey ?? "", deliver: resolveHookDeliver(mapped.action.deliver), diff --git a/src/gateway/server.hooks.e2e.test.ts b/src/gateway/server.hooks.e2e.test.ts index 93a311a60fe..1eb41e0f64e 100644 --- a/src/gateway/server.hooks.e2e.test.ts +++ b/src/gateway/server.hooks.e2e.test.ts @@ -17,6 +17,9 @@ const resolveMainKey = () => resolveMainSessionKeyFromConfig(); describe("gateway server hooks", () => { test("handles auth, wake, and agent flows", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; + testState.agentsConfig = { + list: [{ id: "main", default: true }, { id: "hooks" }], + }; const port = await getFreePort(); const server = await startGatewayServer(port); try { @@ -83,6 +86,48 @@ describe("gateway server hooks", () => { expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini"); drainSystemEvents(resolveMainKey()); + cronIsolatedRun.mockReset(); + cronIsolatedRun.mockResolvedValueOnce({ + status: "ok", + summary: "done", + }); + const resAgentWithId = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ message: "Do it", name: "Email", agentId: "hooks" }), + }); + expect(resAgentWithId.status).toBe(202); + await waitForSystemEvent(); + const routedCall = cronIsolatedRun.mock.calls[0]?.[0] as { + job?: { agentId?: string }; + }; + expect(routedCall?.job?.agentId).toBe("hooks"); + drainSystemEvents(resolveMainKey()); + + cronIsolatedRun.mockReset(); + cronIsolatedRun.mockResolvedValueOnce({ + status: "ok", + summary: "done", + }); + const resAgentUnknown = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ message: "Do it", name: "Email", agentId: "missing-agent" }), + }); + expect(resAgentUnknown.status).toBe(202); + await waitForSystemEvent(); + const fallbackCall = cronIsolatedRun.mock.calls[0]?.[0] as { + job?: { agentId?: string }; + }; + expect(fallbackCall?.job?.agentId).toBe("main"); + drainSystemEvents(resolveMainKey()); + const resQuery = await fetch(`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -153,4 +198,124 @@ describe("gateway server hooks", () => { await server.close(); } }); + + test("enforces hooks.allowedAgentIds for explicit agent routing", async () => { + testState.hooksConfig = { + enabled: true, + token: "hook-secret", + allowedAgentIds: ["hooks"], + mappings: [ + { + match: { path: "mapped" }, + action: "agent", + agentId: "main", + messageTemplate: "Mapped: {{payload.subject}}", + }, + ], + }; + testState.agentsConfig = { + list: [{ id: "main", default: true }, { id: "hooks" }], + }; + const port = await getFreePort(); + const server = await startGatewayServer(port); + try { + cronIsolatedRun.mockReset(); + cronIsolatedRun.mockResolvedValueOnce({ + status: "ok", + summary: "done", + }); + const resNoAgent = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ message: "No explicit agent" }), + }); + expect(resNoAgent.status).toBe(202); + await waitForSystemEvent(); + const noAgentCall = cronIsolatedRun.mock.calls[0]?.[0] as { + job?: { agentId?: string }; + }; + expect(noAgentCall?.job?.agentId).toBeUndefined(); + drainSystemEvents(resolveMainKey()); + + cronIsolatedRun.mockReset(); + cronIsolatedRun.mockResolvedValueOnce({ + status: "ok", + summary: "done", + }); + const resAllowed = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ message: "Allowed", agentId: "hooks" }), + }); + expect(resAllowed.status).toBe(202); + await waitForSystemEvent(); + const allowedCall = cronIsolatedRun.mock.calls[0]?.[0] as { + job?: { agentId?: string }; + }; + expect(allowedCall?.job?.agentId).toBe("hooks"); + drainSystemEvents(resolveMainKey()); + + const resDenied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ message: "Denied", agentId: "main" }), + }); + expect(resDenied.status).toBe(400); + const deniedBody = (await resDenied.json()) as { error?: string }; + expect(deniedBody.error).toContain("hooks.allowedAgentIds"); + + const resMappedDenied = await fetch(`http://127.0.0.1:${port}/hooks/mapped`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ subject: "hello" }), + }); + expect(resMappedDenied.status).toBe(400); + const mappedDeniedBody = (await resMappedDenied.json()) as { error?: string }; + expect(mappedDeniedBody.error).toContain("hooks.allowedAgentIds"); + expect(peekSystemEvents(resolveMainKey()).length).toBe(0); + } finally { + await server.close(); + } + }); + + test("denies explicit agentId when hooks.allowedAgentIds is empty", async () => { + testState.hooksConfig = { + enabled: true, + token: "hook-secret", + allowedAgentIds: [], + }; + testState.agentsConfig = { + list: [{ id: "main", default: true }, { id: "hooks" }], + }; + const port = await getFreePort(); + const server = await startGatewayServer(port); + try { + const resDenied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ message: "Denied", agentId: "hooks" }), + }); + expect(resDenied.status).toBe(400); + const deniedBody = (await resDenied.json()) as { error?: string }; + expect(deniedBody.error).toContain("hooks.allowedAgentIds"); + expect(peekSystemEvents(resolveMainKey()).length).toBe(0); + } finally { + await server.close(); + } + }); }); diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 139e9ef9cf8..e858303a697 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -32,6 +32,7 @@ export function createGatewayHooksRequestHandler(params: { const dispatchAgentHook = (value: { message: string; name: string; + agentId?: string; wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; @@ -48,6 +49,7 @@ export function createGatewayHooksRequestHandler(params: { const now = Date.now(); const job: CronJob = { id: jobId, + agentId: value.agentId, name: value.name, enabled: true, createdAtMs: now,