openclaw/src/gateway/server.hooks.e2e.test.ts
Bill Chirico ca629296c6
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>
2026-02-10 19:23:58 -05:00

322 lines
11 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import {
cronIsolatedRun,
getFreePort,
installGatewayTestHooks,
startGatewayServer,
testState,
waitForSystemEvent,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
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 {
const resNoAuth = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Ping" }),
});
expect(resNoAuth.status).toBe(401);
const resWake = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
});
expect(resWake.status).toBe(200);
const wakeEvents = await waitForSystemEvent();
expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true);
drainSystemEvents(resolveMainKey());
cronIsolatedRun.mockReset();
cronIsolatedRun.mockResolvedValueOnce({
status: "ok",
summary: "done",
});
const resAgent = 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" }),
});
expect(resAgent.status).toBe(202);
const agentEvents = await waitForSystemEvent();
expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true);
drainSystemEvents(resolveMainKey());
cronIsolatedRun.mockReset();
cronIsolatedRun.mockResolvedValueOnce({
status: "ok",
summary: "done",
});
const resAgentModel = 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",
model: "openai/gpt-4.1-mini",
}),
});
expect(resAgentModel.status).toBe(202);
await waitForSystemEvent();
const call = cronIsolatedRun.mock.calls[0]?.[0] as {
job?: { payload?: { model?: string } };
};
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" },
body: JSON.stringify({ text: "Query auth" }),
});
expect(resQuery.status).toBe(400);
const resBadChannel = 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: "Nope", channel: "sms" }),
});
expect(resBadChannel.status).toBe(400);
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
const resHeader = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-openclaw-token": "hook-secret",
},
body: JSON.stringify({ text: "Header auth" }),
});
expect(resHeader.status).toBe(200);
const headerEvents = await waitForSystemEvent();
expect(headerEvents.some((e) => e.includes("Header auth"))).toBe(true);
drainSystemEvents(resolveMainKey());
const resGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "GET",
headers: { Authorization: "Bearer hook-secret" },
});
expect(resGet.status).toBe(405);
const resBlankText = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ text: " " }),
});
expect(resBlankText.status).toBe(400);
const resBlankMessage = 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: " " }),
});
expect(resBlankMessage.status).toBe(400);
const resBadJson = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: "{",
});
expect(resBadJson.status).toBe(400);
} finally {
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();
}
});
});