import { describe, expect, test } from "vitest"; import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { cronIsolatedRun, installGatewayTestHooks, testState, withGatewayServer, 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" }], }; await withGatewayServer(async ({ port }) => { 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.mockClear(); 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.mockClear(); 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] as unknown[] | undefined)?.[0] as { job?: { payload?: { model?: string } }; }; expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini"); drainSystemEvents(resolveMainKey()); cronIsolatedRun.mockClear(); 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] as unknown[] | undefined)?.[0] as { job?: { agentId?: string }; }; expect(routedCall?.job?.agentId).toBe("hooks"); drainSystemEvents(resolveMainKey()); cronIsolatedRun.mockClear(); 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] as unknown[] | undefined)?.[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); }); }); test("rejects request sessionKey unless hooks.allowRequestSessionKey is enabled", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; await withGatewayServer(async ({ port }) => { const denied = 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", sessionKey: "agent:main:dm:u99999", }), }); expect(denied.status).toBe(400); const deniedBody = (await denied.json()) as { error?: string }; expect(deniedBody.error).toContain("hooks.allowRequestSessionKey"); }); }); test("respects hooks session policy for request + mapping session keys", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret", allowRequestSessionKey: true, allowedSessionKeyPrefixes: ["hook:"], defaultSessionKey: "hook:ingress", mappings: [ { match: { path: "mapped-ok" }, action: "agent", messageTemplate: "Mapped: {{payload.subject}}", sessionKey: "hook:mapped:{{payload.id}}", }, { match: { path: "mapped-bad" }, action: "agent", messageTemplate: "Mapped: {{payload.subject}}", sessionKey: "agent:main:main", }, ], }; await withGatewayServer(async ({ port }) => { cronIsolatedRun.mockClear(); cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" }); const defaultRoute = 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 key" }), }); expect(defaultRoute.status).toBe(202); await waitForSystemEvent(); const defaultCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as | { sessionKey?: string } | undefined; expect(defaultCall?.sessionKey).toBe("hook:ingress"); drainSystemEvents(resolveMainKey()); cronIsolatedRun.mockClear(); cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" }); const mappedOk = await fetch(`http://127.0.0.1:${port}/hooks/mapped-ok`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: JSON.stringify({ subject: "hello", id: "42" }), }); expect(mappedOk.status).toBe(202); await waitForSystemEvent(); const mappedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as | { sessionKey?: string } | undefined; expect(mappedCall?.sessionKey).toBe("hook:mapped:42"); drainSystemEvents(resolveMainKey()); const requestBadPrefix = 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: "Bad key", sessionKey: "agent:main:main", }), }); expect(requestBadPrefix.status).toBe(400); const mappedBadPrefix = await fetch(`http://127.0.0.1:${port}/hooks/mapped-bad`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer hook-secret", }, body: JSON.stringify({ subject: "hello" }), }); expect(mappedBadPrefix.status).toBe(400); }); }); 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" }], }; await withGatewayServer(async ({ port }) => { cronIsolatedRun.mockClear(); 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] as unknown[] | undefined)?.[0] as { job?: { agentId?: string }; }; expect(noAgentCall?.job?.agentId).toBeUndefined(); drainSystemEvents(resolveMainKey()); cronIsolatedRun.mockClear(); 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] as unknown[] | undefined)?.[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); }); }); 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" }], }; await withGatewayServer(async ({ port }) => { 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); }); }); test("throttles repeated hook auth failures and resets after success", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; await withGatewayServer(async ({ port }) => { const firstFail = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer wrong", }, body: JSON.stringify({ text: "blocked" }), }); expect(firstFail.status).toBe(401); let throttled: Response | null = null; for (let i = 0; i < 20; i++) { throttled = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer wrong", }, body: JSON.stringify({ text: "blocked" }), }); } expect(throttled?.status).toBe(429); expect(throttled?.headers.get("retry-after")).toBeTruthy(); const allowed = 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: "auth reset" }), }); expect(allowed.status).toBe(200); await waitForSystemEvent(); drainSystemEvents(resolveMainKey()); const failAfterSuccess = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer wrong", }, body: JSON.stringify({ text: "blocked" }), }); expect(failAfterSuccess.status).toBe(401); }); }); });