mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 01:28:27 +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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user