mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 06:05:03 +00:00
refactor: dedupe channel and gateway surfaces
This commit is contained in:
@@ -12,29 +12,47 @@ import {
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const resolveMainKey = () => resolveMainSessionKeyFromConfig();
|
||||
const HOOK_TOKEN = "hook-secret";
|
||||
|
||||
function buildHookJsonHeaders(options?: {
|
||||
token?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
}): Record<string, string> {
|
||||
const token = options?.token === undefined ? HOOK_TOKEN : options.token;
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options?.headers,
|
||||
};
|
||||
}
|
||||
|
||||
async function postHook(
|
||||
port: number,
|
||||
path: string,
|
||||
body: Record<string, unknown> | string,
|
||||
options?: {
|
||||
token?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Promise<Response> {
|
||||
return fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: "POST",
|
||||
headers: buildHookJsonHeaders(options),
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway server hooks", () => {
|
||||
test("handles auth, wake, and agent flows", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
||||
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" }),
|
||||
});
|
||||
const resNoAuth = await postHook(port, "/hooks/wake", { text: "Ping" }, { token: null });
|
||||
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" }),
|
||||
});
|
||||
const resWake = await postHook(port, "/hooks/wake", { text: "Ping", mode: "next-heartbeat" });
|
||||
expect(resWake.status).toBe(200);
|
||||
const wakeEvents = await waitForSystemEvent();
|
||||
expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true);
|
||||
@@ -45,14 +63,7 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
});
|
||||
const resAgent = await postHook(port, "/hooks/agent", { 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);
|
||||
@@ -63,17 +74,10 @@ describe("gateway server hooks", () => {
|
||||
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",
|
||||
}),
|
||||
const resAgentModel = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
});
|
||||
expect(resAgentModel.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
@@ -88,13 +92,10 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
const resAgentWithId = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
agentId: "hooks",
|
||||
});
|
||||
expect(resAgentWithId.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
@@ -109,13 +110,10 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
const resAgentUnknown = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
agentId: "missing-agent",
|
||||
});
|
||||
expect(resAgentUnknown.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
@@ -125,32 +123,27 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
});
|
||||
const resQuery = await postHook(
|
||||
port,
|
||||
"/hooks/wake?token=hook-secret",
|
||||
{ text: "Query auth" },
|
||||
{ token: null },
|
||||
);
|
||||
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" }),
|
||||
const resBadChannel = await postHook(port, "/hooks/agent", {
|
||||
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" }),
|
||||
});
|
||||
const resHeader = await postHook(
|
||||
port,
|
||||
"/hooks/wake",
|
||||
{ text: "Header auth" },
|
||||
{ token: null, headers: { "x-openclaw-token": HOOK_TOKEN } },
|
||||
);
|
||||
expect(resHeader.status).toBe(200);
|
||||
const headerEvents = await waitForSystemEvent();
|
||||
expect(headerEvents.some((e) => e.includes("Header auth"))).toBe(true);
|
||||
@@ -162,51 +155,23 @@ describe("gateway server hooks", () => {
|
||||
});
|
||||
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: " " }),
|
||||
});
|
||||
const resBlankText = await postHook(port, "/hooks/wake", { 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: " " }),
|
||||
});
|
||||
const resBlankMessage = await postHook(port, "/hooks/agent", { 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: "{",
|
||||
});
|
||||
const resBadJson = await postHook(port, "/hooks/wake", "{");
|
||||
expect(resBadJson.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects request sessionKey unless hooks.allowRequestSessionKey is enabled", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
||||
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",
|
||||
}),
|
||||
const denied = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
sessionKey: "agent:main:dm:u99999",
|
||||
});
|
||||
expect(denied.status).toBe(400);
|
||||
const deniedBody = (await denied.json()) as { error?: string };
|
||||
@@ -217,7 +182,7 @@ describe("gateway server hooks", () => {
|
||||
test("respects hooks session policy for request + mapping session keys", async () => {
|
||||
testState.hooksConfig = {
|
||||
enabled: true,
|
||||
token: "hook-secret",
|
||||
token: HOOK_TOKEN,
|
||||
allowRequestSessionKey: true,
|
||||
allowedSessionKeyPrefixes: ["hook:"],
|
||||
defaultSessionKey: "hook:ingress",
|
||||
@@ -240,14 +205,7 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
});
|
||||
const defaultRoute = await postHook(port, "/hooks/agent", { message: "No key" });
|
||||
expect(defaultRoute.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
const defaultCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
|
||||
@@ -258,14 +216,7 @@ describe("gateway server hooks", () => {
|
||||
|
||||
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" }),
|
||||
});
|
||||
const mappedOk = await postHook(port, "/hooks/mapped-ok", { subject: "hello", id: "42" });
|
||||
expect(mappedOk.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
const mappedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
|
||||
@@ -274,27 +225,13 @@ describe("gateway server hooks", () => {
|
||||
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",
|
||||
}),
|
||||
const requestBadPrefix = await postHook(port, "/hooks/agent", {
|
||||
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" }),
|
||||
});
|
||||
const mappedBadPrefix = await postHook(port, "/hooks/mapped-bad", { subject: "hello" });
|
||||
expect(mappedBadPrefix.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -302,7 +239,7 @@ describe("gateway server hooks", () => {
|
||||
test("normalizes duplicate target-agent prefixes before isolated dispatch", async () => {
|
||||
testState.hooksConfig = {
|
||||
enabled: true,
|
||||
token: "hook-secret",
|
||||
token: HOOK_TOKEN,
|
||||
allowRequestSessionKey: true,
|
||||
allowedSessionKeyPrefixes: ["hook:", "agent:"],
|
||||
};
|
||||
@@ -316,18 +253,11 @@ describe("gateway server hooks", () => {
|
||||
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",
|
||||
agentId: "hooks",
|
||||
sessionKey: "agent:hooks:slack:channel:c123",
|
||||
}),
|
||||
const resAgent = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
agentId: "hooks",
|
||||
sessionKey: "agent:hooks:slack:channel:c123",
|
||||
});
|
||||
expect(resAgent.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
@@ -344,7 +274,7 @@ describe("gateway server hooks", () => {
|
||||
test("enforces hooks.allowedAgentIds for explicit agent routing", async () => {
|
||||
testState.hooksConfig = {
|
||||
enabled: true,
|
||||
token: "hook-secret",
|
||||
token: HOOK_TOKEN,
|
||||
allowedAgentIds: ["hooks"],
|
||||
mappings: [
|
||||
{
|
||||
@@ -364,14 +294,7 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
});
|
||||
const resNoAgent = await postHook(port, "/hooks/agent", { message: "No explicit agent" });
|
||||
expect(resNoAgent.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
const noAgentCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
@@ -385,13 +308,9 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
const resAllowed = await postHook(port, "/hooks/agent", {
|
||||
message: "Allowed",
|
||||
agentId: "hooks",
|
||||
});
|
||||
expect(resAllowed.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
@@ -401,26 +320,15 @@ describe("gateway server hooks", () => {
|
||||
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" }),
|
||||
const resDenied = await postHook(port, "/hooks/agent", {
|
||||
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" }),
|
||||
});
|
||||
const resMappedDenied = await postHook(port, "/hooks/mapped", { subject: "hello" });
|
||||
expect(resMappedDenied.status).toBe(400);
|
||||
const mappedDeniedBody = (await resMappedDenied.json()) as { error?: string };
|
||||
expect(mappedDeniedBody.error).toContain("hooks.allowedAgentIds");
|
||||
@@ -431,20 +339,16 @@ describe("gateway server hooks", () => {
|
||||
test("denies explicit agentId when hooks.allowedAgentIds is empty", async () => {
|
||||
testState.hooksConfig = {
|
||||
enabled: true,
|
||||
token: "hook-secret",
|
||||
token: HOOK_TOKEN,
|
||||
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" }),
|
||||
const resDenied = await postHook(port, "/hooks/agent", {
|
||||
message: "Denied",
|
||||
agentId: "hooks",
|
||||
});
|
||||
expect(resDenied.status).toBe(400);
|
||||
const deniedBody = (await resDenied.json()) as { error?: string };
|
||||
@@ -454,52 +358,34 @@ describe("gateway server hooks", () => {
|
||||
});
|
||||
|
||||
test("throttles repeated hook auth failures and resets after success", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: "hook-secret" };
|
||||
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
||||
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" }),
|
||||
});
|
||||
const firstFail = await postHook(
|
||||
port,
|
||||
"/hooks/wake",
|
||||
{ text: "blocked" },
|
||||
{ token: "wrong" },
|
||||
);
|
||||
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" }),
|
||||
});
|
||||
throttled = await postHook(port, "/hooks/wake", { text: "blocked" }, { token: "wrong" });
|
||||
}
|
||||
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" }),
|
||||
});
|
||||
const allowed = await postHook(port, "/hooks/wake", { 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" }),
|
||||
});
|
||||
const failAfterSuccess = await postHook(
|
||||
port,
|
||||
"/hooks/wake",
|
||||
{ text: "blocked" },
|
||||
{ token: "wrong" },
|
||||
);
|
||||
expect(failAfterSuccess.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user