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

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

View File

@@ -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:<uuid>`. 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`

View File

@@ -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/<name>` → 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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,

View File

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

View File

@@ -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();
}
});
});

View File

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