mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user