mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 20:24:47 +00:00
Keep queued system-event owner downgrades as structured runtime metadata while rendering the model-visible prompt as plain `System:` lines. This preserves least-privilege wakeups for webhook/node/exec/cron/reaction/hook producers, keeps legacy `trusted: false` compatibility for installed plugins and older hosts, and updates representative gateway, agent, cron, plugin, and OpenGrep coverage.
922 lines
31 KiB
TypeScript
922 lines
31 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
|
|
import {
|
|
drainSystemEvents,
|
|
peekSystemEventEntries,
|
|
peekSystemEvents,
|
|
} from "../infra/system-events.js";
|
|
import { DEDUPE_TTL_MS } from "./server-constants.js";
|
|
import {
|
|
cronIsolatedRun,
|
|
installGatewayTestHooks,
|
|
testState,
|
|
withGatewayServer,
|
|
waitForSystemEvent,
|
|
} from "./test-helpers.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
|
|
const resolveMainKey = () => resolveMainSessionKeyFromConfig();
|
|
const HOOK_TOKEN = "hook-secret";
|
|
const HOOKS_MAIN_SESSION_KEY = "agent:hooks:main";
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function requireNonEmptyString(value: string | null | undefined, label: string): string {
|
|
if (!value) {
|
|
throw new Error(`expected ${label}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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),
|
|
});
|
|
}
|
|
|
|
function setMainAndHooksAgents(): void {
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main", default: true }, { id: "hooks" }],
|
|
};
|
|
}
|
|
|
|
function mockIsolatedRunOkOnce(): void {
|
|
cronIsolatedRun.mockClear();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
}
|
|
|
|
function mockIsolatedRunOk(): void {
|
|
cronIsolatedRun.mockClear();
|
|
cronIsolatedRun.mockResolvedValue({
|
|
status: "ok",
|
|
summary: "done",
|
|
});
|
|
}
|
|
|
|
async function waitForCronIsolatedRuns(count: number, timeoutMs = 2_000): Promise<void> {
|
|
await expect
|
|
.poll(() => cronIsolatedRun.mock.calls.length, { timeout: timeoutMs, interval: 10 })
|
|
.toBe(count);
|
|
}
|
|
|
|
type HookCronRunCall = {
|
|
sessionKey?: string;
|
|
job?: {
|
|
agentId?: string;
|
|
payload?: {
|
|
externalContentSource?: string;
|
|
model?: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
function cronRunCall(index = 0): HookCronRunCall {
|
|
const call = cronIsolatedRun.mock.calls.at(index)?.[0];
|
|
if (!call || typeof call !== "object") {
|
|
throw new Error(`expected cron isolated run call ${index + 1}`);
|
|
}
|
|
return call as HookCronRunCall;
|
|
}
|
|
|
|
async function postAgentHookWithIdempotency(
|
|
port: number,
|
|
idempotencyKey: string,
|
|
headers?: Record<string, string>,
|
|
) {
|
|
const response = await postHook(
|
|
port,
|
|
"/hooks/agent",
|
|
{ message: "Do it", name: "Email" },
|
|
{ headers: { "Idempotency-Key": idempotencyKey, ...headers } },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
return response;
|
|
}
|
|
|
|
async function expectFirstHookDelivery(
|
|
port: number,
|
|
idempotencyKey: string,
|
|
headers?: Record<string, string>,
|
|
) {
|
|
const first = await postAgentHookWithIdempotency(port, idempotencyKey, headers);
|
|
const firstBody = (await first.json()) as { runId?: string };
|
|
requireNonEmptyString(firstBody.runId, "first hook run id");
|
|
await waitForSystemEvent(5_000);
|
|
drainSystemEvents(resolveMainKey());
|
|
return firstBody;
|
|
}
|
|
|
|
async function expectHookAgentSessionRouting(params: {
|
|
port: number;
|
|
requestSessionKey: string;
|
|
expectedSessionKey: string;
|
|
}) {
|
|
mockIsolatedRunOkOnce();
|
|
|
|
const resAgent = await postHook(params.port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
agentId: "hooks",
|
|
sessionKey: params.requestSessionKey,
|
|
});
|
|
expect(resAgent.status).toBe(200);
|
|
await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
|
|
|
|
const routedCall = cronRunCall();
|
|
expect(routedCall?.job?.agentId).toBe("hooks");
|
|
expect(routedCall?.sessionKey).toBe(params.expectedSessionKey);
|
|
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
|
|
}
|
|
|
|
async function waitForSystemEventTexts(sessionKey: string, timeoutMs = 2_000) {
|
|
await expect
|
|
.poll(() => peekSystemEventEntries(sessionKey).map((event) => event.text), {
|
|
timeout: timeoutMs,
|
|
interval: 10,
|
|
})
|
|
.not.toHaveLength(0);
|
|
return peekSystemEventEntries(sessionKey).map((event) => event.text);
|
|
}
|
|
|
|
async function writeHookTransformModule(moduleName: string, source: string): Promise<void> {
|
|
const configPath = requireNonEmptyString(
|
|
process.env.OPENCLAW_CONFIG_PATH,
|
|
"OPENCLAW_CONFIG_PATH",
|
|
);
|
|
const transformsDir = path.join(path.dirname(configPath), "hooks", "transforms");
|
|
await fs.mkdir(transformsDir, { recursive: true });
|
|
await fs.writeFile(path.join(transformsDir, moduleName), source, "utf-8");
|
|
}
|
|
|
|
describe("gateway server hooks", () => {
|
|
test("handles auth, wake, and agent flows", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
setMainAndHooksAgents();
|
|
await withGatewayServer(async ({ port }) => {
|
|
const resNoAuth = await postHook(port, "/hooks/wake", { text: "Ping" }, { token: null });
|
|
expect(resNoAuth.status).toBe(401);
|
|
|
|
const resWake = await postHook(port, "/hooks/wake", { text: "Ping", mode: "next-heartbeat" });
|
|
expect(resWake.status).toBe(200);
|
|
const wakeEvents = await waitForSystemEvent();
|
|
expect(wakeEvents.join("\n")).toContain("Ping");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
mockIsolatedRunOkOnce();
|
|
const resAgent = await postHook(port, "/hooks/agent", { message: "Do it", name: "Email" });
|
|
expect(resAgent.status).toBe(200);
|
|
const agentEvents = await waitForSystemEvent();
|
|
expect(agentEvents.join("\n")).toContain("Hook Email: done");
|
|
const firstCall = cronRunCall();
|
|
expect(firstCall?.job?.payload?.externalContentSource).toBe("webhook");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
mockIsolatedRunOkOnce();
|
|
const resAgentModel = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
model: "openai/gpt-4.1-mini",
|
|
});
|
|
expect(resAgentModel.status).toBe(200);
|
|
await waitForSystemEvent();
|
|
const call = cronRunCall();
|
|
expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
mockIsolatedRunOkOnce();
|
|
const resAgentWithId = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
agentId: "hooks",
|
|
});
|
|
expect(resAgentWithId.status).toBe(200);
|
|
await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
|
|
const routedCall = cronRunCall();
|
|
expect(routedCall?.job?.agentId).toBe("hooks");
|
|
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
|
|
|
|
mockIsolatedRunOkOnce();
|
|
const resAgentUnknown = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
agentId: "missing-agent",
|
|
});
|
|
expect(resAgentUnknown.status).toBe(200);
|
|
await waitForSystemEvent();
|
|
const fallbackCall = cronRunCall();
|
|
expect(fallbackCall?.job?.agentId).toBe("main");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const resQuery = await postHook(
|
|
port,
|
|
"/hooks/wake?token=hook-secret",
|
|
{ text: "Query auth" },
|
|
{ token: null },
|
|
);
|
|
expect(resQuery.status).toBe(400);
|
|
|
|
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 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.join("\n")).toContain("Header auth");
|
|
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 postHook(port, "/hooks/wake", { text: " " });
|
|
expect(resBlankText.status).toBe(400);
|
|
|
|
const resBlankMessage = await postHook(port, "/hooks/agent", { message: " " });
|
|
expect(resBlankMessage.status).toBe(400);
|
|
|
|
const resBadJson = await postHook(port, "/hooks/wake", "{");
|
|
expect(resBadJson.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
test("preserves mapped hook provenance across async dispatch", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
mappings: [
|
|
{
|
|
match: { path: "gmail" },
|
|
action: "agent",
|
|
messageTemplate: "New email from {{messages[0].from}}",
|
|
sessionKey: "main",
|
|
},
|
|
],
|
|
};
|
|
setMainAndHooksAgents();
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
mockIsolatedRunOkOnce();
|
|
const response = await postHook(port, "/hooks/gmail", {
|
|
source: "gmail",
|
|
messages: [{ id: "msg-1", from: "Ada", subject: "Hello", snippet: "Hi", body: "Body" }],
|
|
});
|
|
expect(response.status).toBe(200);
|
|
await expect
|
|
.poll(() => cronIsolatedRun.mock.calls.length, { timeout: 2_000, interval: 10 })
|
|
.toBe(1);
|
|
|
|
const call = cronRunCall();
|
|
expect(call?.sessionKey).toBe("main");
|
|
expect(call?.job?.payload?.externalContentSource).toBe("gmail");
|
|
drainSystemEvents(resolveMainKey());
|
|
});
|
|
});
|
|
|
|
test("routes explicit-agent hook completion events to the target agent main session", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
setMainAndHooksAgents();
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
mockIsolatedRunOkOnce();
|
|
const resAgent = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
agentId: "hooks",
|
|
});
|
|
expect(resAgent.status).toBe(200);
|
|
|
|
const targetEvents = await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
|
|
expect(targetEvents.join("\n")).toContain("Hook Email: done");
|
|
expect(peekSystemEventEntries(resolveMainKey())).toStrictEqual([]);
|
|
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
|
|
});
|
|
});
|
|
|
|
test("hook announcement policy keeps no-deliver success silent without hiding failures", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped-silent" },
|
|
action: "agent",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
deliver: false,
|
|
},
|
|
],
|
|
};
|
|
setMainAndHooksAgents();
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
cronIsolatedRun.mockClear();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
delivered: false,
|
|
});
|
|
const directSilent = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
deliver: false,
|
|
});
|
|
expect(directSilent.status).toBe(200);
|
|
await waitForCronIsolatedRuns(1);
|
|
expect(peekSystemEventEntries(resolveMainKey())).toStrictEqual([]);
|
|
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "mapped done",
|
|
delivered: false,
|
|
});
|
|
const mappedSilent = await postHook(port, "/hooks/mapped-silent", { subject: "Email" });
|
|
expect(mappedSilent.status).toBe(200);
|
|
await waitForCronIsolatedRuns(2);
|
|
expect(peekSystemEventEntries(resolveMainKey())).toStrictEqual([]);
|
|
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "error",
|
|
summary: "boom",
|
|
delivered: false,
|
|
});
|
|
const directFailure = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
deliver: false,
|
|
});
|
|
expect(directFailure.status).toBe(200);
|
|
const failureEvents = await waitForSystemEventTexts(resolveMainKey());
|
|
expect(failureEvents).toContain("Hook Email (error): boom");
|
|
drainSystemEvents(resolveMainKey());
|
|
});
|
|
});
|
|
|
|
test("hook announcement policy suppresses fallback after attempted delivery", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
setMainAndHooksAgents();
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
cronIsolatedRun.mockClear();
|
|
cronIsolatedRun.mockResolvedValueOnce({
|
|
status: "ok",
|
|
summary: "done",
|
|
delivered: false,
|
|
deliveryAttempted: true,
|
|
});
|
|
const response = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
await waitForCronIsolatedRuns(1);
|
|
expect(peekSystemEventEntries(resolveMainKey())).toStrictEqual([]);
|
|
});
|
|
});
|
|
|
|
test("queues direct and mapped wake payloads with owner-downgrade metadata", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped-wake" },
|
|
action: "wake",
|
|
textTemplate: "Mapped wake: {{payload.subject}}",
|
|
},
|
|
],
|
|
};
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
const direct = await postHook(port, "/hooks/wake", { text: "Direct wake" });
|
|
expect(direct.status).toBe(200);
|
|
await waitForSystemEvent(5_000);
|
|
const directEvents = peekSystemEventEntries(resolveMainKey());
|
|
expect(directEvents).toHaveLength(1);
|
|
expect(directEvents[0]?.text).toBe("Direct wake");
|
|
expect(directEvents[0]?.forceSenderIsOwnerFalse).toBe(true);
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const mapped = await postHook(port, "/hooks/mapped-wake", { subject: "Email" });
|
|
expect(mapped.status).toBe(200);
|
|
await waitForSystemEvent(5_000);
|
|
const mappedEvents = peekSystemEventEntries(resolveMainKey());
|
|
expect(mappedEvents).toHaveLength(1);
|
|
expect(mappedEvents[0]?.text).toBe("Mapped wake: Email");
|
|
expect(mappedEvents[0]?.forceSenderIsOwnerFalse).toBe(true);
|
|
drainSystemEvents(resolveMainKey());
|
|
});
|
|
});
|
|
|
|
test("rejects request sessionKey unless hooks.allowRequestSessionKey is enabled", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
await withGatewayServer(async ({ port }) => {
|
|
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 };
|
|
expect(deniedBody.error).toContain("hooks.allowRequestSessionKey");
|
|
});
|
|
});
|
|
|
|
test("respects hooks session policy for request + mapping session keys", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
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(200);
|
|
await waitForSystemEvent();
|
|
const defaultCall = cronRunCall();
|
|
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(200);
|
|
await waitForSystemEvent();
|
|
const mappedCall = cronRunCall();
|
|
expect(mappedCall?.sessionKey).toBe("hook:mapped:42");
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const requestBadPrefix = await postHook(port, "/hooks/agent", {
|
|
message: "Bad key",
|
|
sessionKey: "agent:main:main",
|
|
});
|
|
expect(requestBadPrefix.status).toBe(400);
|
|
|
|
const mappedBadPrefix = await postHook(port, "/hooks/mapped-bad", { subject: "hello" });
|
|
expect(mappedBadPrefix.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
test("enforces templated vs static mapping session keys on /hooks/<mapping>", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped-templated" },
|
|
action: "agent",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
sessionKey: "hook:gmail:{{payload.id}}",
|
|
},
|
|
{
|
|
match: { path: "mapped-static" },
|
|
action: "agent",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
sessionKey: "hook:gmail:fixed",
|
|
},
|
|
],
|
|
};
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
const templated = await postHook(port, "/hooks/mapped-templated", {
|
|
subject: "hello",
|
|
id: "42",
|
|
});
|
|
expect(templated.status).toBe(400);
|
|
const templatedBody = (await templated.json()) as { error?: string };
|
|
expect(templatedBody.error).toContain("hooks.allowRequestSessionKey");
|
|
expect(cronIsolatedRun).not.toHaveBeenCalled();
|
|
|
|
mockIsolatedRunOkOnce();
|
|
const staticMapped = await postHook(port, "/hooks/mapped-static", {
|
|
subject: "hello",
|
|
});
|
|
expect(staticMapped.status).toBe(200);
|
|
await waitForSystemEvent();
|
|
const staticCall = cronRunCall();
|
|
expect(staticCall?.sessionKey).toBe("hook:gmail:fixed");
|
|
drainSystemEvents(resolveMainKey());
|
|
});
|
|
});
|
|
|
|
test("treats malformed transform sessionKeySource as templated on /hooks/<mapping>", async () => {
|
|
await writeHookTransformModule(
|
|
"mapped-invalid-session-key-source.mjs",
|
|
[
|
|
"export default () => ({",
|
|
' kind: "agent",',
|
|
' message: "Mapped: from transform",',
|
|
' sessionKey: "hook:gmail:from-transform",',
|
|
' sessionKeySource: "bogus",',
|
|
"});",
|
|
].join("\n"),
|
|
);
|
|
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"],
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped-invalid-session-key-source" },
|
|
action: "agent",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
transform: { module: "mapped-invalid-session-key-source.mjs" },
|
|
},
|
|
],
|
|
};
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
const response = await postHook(port, "/hooks/mapped-invalid-session-key-source", {
|
|
subject: "hello",
|
|
});
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as { error?: string };
|
|
expect(body.error).toContain("hooks.allowRequestSessionKey");
|
|
expect(cronIsolatedRun).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
test("preserves target-agent prefixes before isolated dispatch", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowRequestSessionKey: true,
|
|
allowedSessionKeyPrefixes: ["hook:", "agent:"],
|
|
};
|
|
setMainAndHooksAgents();
|
|
await withGatewayServer(async ({ port }) => {
|
|
await expectHookAgentSessionRouting({
|
|
port,
|
|
requestSessionKey: "agent:hooks:slack:channel:c123",
|
|
expectedSessionKey: "agent:hooks:slack:channel:c123",
|
|
});
|
|
});
|
|
});
|
|
|
|
test("rebinds mismatched agent prefixes to the hook target before isolated dispatch", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowRequestSessionKey: true,
|
|
allowedSessionKeyPrefixes: ["hook:", "agent:"],
|
|
};
|
|
setMainAndHooksAgents();
|
|
await withGatewayServer(async ({ port }) => {
|
|
await expectHookAgentSessionRouting({
|
|
port,
|
|
requestSessionKey: "agent:main:slack:channel:c123",
|
|
expectedSessionKey: "agent:hooks:slack:channel:c123",
|
|
});
|
|
});
|
|
});
|
|
|
|
test("rejects rebinding into a session namespace that is not allowlisted", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowRequestSessionKey: true,
|
|
allowedSessionKeyPrefixes: ["hook:", "agent:main:"],
|
|
};
|
|
setMainAndHooksAgents();
|
|
await withGatewayServer(async ({ port }) => {
|
|
const denied = await postHook(port, "/hooks/agent", {
|
|
message: "Do it",
|
|
name: "Email",
|
|
agentId: "hooks",
|
|
sessionKey: "agent:main:slack:channel:c123",
|
|
});
|
|
expect(denied.status).toBe(400);
|
|
const body = (await denied.json()) as { error?: string };
|
|
expect(body.error).toContain("sessionKey must start with one of");
|
|
expect(cronIsolatedRun).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
test("rejects mapped hook session rebinding into a disallowed target-agent prefix", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowRequestSessionKey: true,
|
|
allowedSessionKeyPrefixes: ["hook:", "agent:main:"],
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped-rebind-denied" },
|
|
action: "agent",
|
|
agentId: "hooks",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
sessionKey: "agent:main:slack:channel:c123",
|
|
},
|
|
],
|
|
};
|
|
setMainAndHooksAgents();
|
|
await withGatewayServer(async ({ port }) => {
|
|
const denied = await postHook(port, "/hooks/mapped-rebind-denied", { subject: "hello" });
|
|
expect(denied.status).toBe(400);
|
|
const body = (await denied.json()) as { error?: string };
|
|
expect(body.error).toContain("sessionKey must start with one of");
|
|
expect(cronIsolatedRun).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
test("dedupes repeated /hooks/agent deliveries by idempotency key", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
await withGatewayServer(async ({ port }) => {
|
|
mockIsolatedRunOk();
|
|
const firstBody = await expectFirstHookDelivery(port, "hook-idem-1");
|
|
expect(cronIsolatedRun).toHaveBeenCalledTimes(1);
|
|
|
|
const second = await postAgentHookWithIdempotency(port, "hook-idem-1");
|
|
const secondBody = (await second.json()) as { runId?: string };
|
|
expect(secondBody.runId).toBe(firstBody.runId);
|
|
expect(cronIsolatedRun).toHaveBeenCalledTimes(1);
|
|
expect(peekSystemEvents(resolveMainKey())).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
test("dedupes hook retries even when trusted-proxy client IP changes", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
const configPath = requireNonEmptyString(
|
|
process.env.OPENCLAW_CONFIG_PATH,
|
|
"OPENCLAW_CONFIG_PATH",
|
|
);
|
|
await fs.writeFile(
|
|
configPath,
|
|
JSON.stringify({ gateway: { trustedProxies: ["127.0.0.1"] } }, null, 2),
|
|
"utf-8",
|
|
);
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
mockIsolatedRunOk();
|
|
const firstBody = await expectFirstHookDelivery(port, "hook-idem-forwarded", {
|
|
"X-Forwarded-For": "198.51.100.10",
|
|
});
|
|
const second = await postAgentHookWithIdempotency(port, "hook-idem-forwarded", {
|
|
"X-Forwarded-For": "203.0.113.25",
|
|
});
|
|
const secondBody = (await second.json()) as { runId?: string };
|
|
expect(secondBody.runId).toBe(firstBody.runId);
|
|
expect(cronIsolatedRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
test("does not retain oversized idempotency keys for replay dedupe", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
const oversizedKey = "x".repeat(257);
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
mockIsolatedRunOk();
|
|
await expectFirstHookDelivery(port, oversizedKey);
|
|
await postAgentHookWithIdempotency(port, oversizedKey);
|
|
await waitForSystemEvent();
|
|
|
|
expect(cronIsolatedRun).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
test("expires hook idempotency entries from first delivery time", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
|
|
await withGatewayServer(async ({ port }) => {
|
|
mockIsolatedRunOk();
|
|
|
|
const firstNowSpy = vi.spyOn(Date, "now");
|
|
firstNowSpy.mockReturnValue(1_000_000);
|
|
const first = await postAgentHookWithIdempotency(port, "fixed-window-idem");
|
|
firstNowSpy.mockRestore();
|
|
|
|
const firstBody = (await first.json()) as { runId?: string };
|
|
requireNonEmptyString(firstBody.runId, "first hook run id");
|
|
await waitForSystemEvent();
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const secondNowSpy = vi.spyOn(Date, "now");
|
|
secondNowSpy.mockReturnValue(1_000_000 + DEDUPE_TTL_MS - 1);
|
|
const second = await postHook(
|
|
port,
|
|
"/hooks/agent",
|
|
{ message: "Do it", name: "Email" },
|
|
{ headers: { "Idempotency-Key": "fixed-window-idem" } },
|
|
);
|
|
secondNowSpy.mockRestore();
|
|
expect(second.status).toBe(200);
|
|
const secondBody = (await second.json()) as { runId?: string };
|
|
expect(secondBody.runId).toBe(firstBody.runId);
|
|
expect(cronIsolatedRun).toHaveBeenCalledTimes(1);
|
|
|
|
const thirdNowSpy = vi.spyOn(Date, "now");
|
|
thirdNowSpy.mockReturnValue(1_000_000 + DEDUPE_TTL_MS + 1);
|
|
const third = await postHook(
|
|
port,
|
|
"/hooks/agent",
|
|
{ message: "Do it", name: "Email" },
|
|
{ headers: { "Idempotency-Key": "fixed-window-idem" } },
|
|
);
|
|
thirdNowSpy.mockRestore();
|
|
expect(third.status).toBe(200);
|
|
const thirdBody = (await third.json()) as { runId?: string };
|
|
requireNonEmptyString(thirdBody.runId, "third hook run id");
|
|
expect(thirdBody.runId).not.toBe(firstBody.runId);
|
|
expect(cronIsolatedRun).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
test("enforces hooks.allowedAgentIds for explicit agent routing", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowedAgentIds: ["hooks"],
|
|
mappings: [
|
|
{
|
|
match: { path: "mapped" },
|
|
action: "agent",
|
|
agentId: "main",
|
|
messageTemplate: "Mapped: {{payload.subject}}",
|
|
},
|
|
],
|
|
};
|
|
setMainAndHooksAgents();
|
|
await withGatewayServer(async ({ port }) => {
|
|
mockIsolatedRunOkOnce();
|
|
const resNoAgent = await postHook(port, "/hooks/agent", { message: "No explicit agent" });
|
|
expect(resNoAgent.status).toBe(200);
|
|
await waitForSystemEvent();
|
|
const noAgentCall = cronRunCall();
|
|
expect(noAgentCall?.job?.agentId).toBeUndefined();
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
mockIsolatedRunOkOnce();
|
|
const resAllowed = await postHook(port, "/hooks/agent", {
|
|
message: "Allowed",
|
|
agentId: "hooks",
|
|
});
|
|
expect(resAllowed.status).toBe(200);
|
|
await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
|
|
const allowedCall = cronRunCall();
|
|
expect(allowedCall?.job?.agentId).toBe("hooks");
|
|
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
|
|
|
|
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 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");
|
|
expect(peekSystemEvents(resolveMainKey()).length).toBe(0);
|
|
});
|
|
});
|
|
|
|
test("denies explicit agentId when hooks.allowedAgentIds is empty", async () => {
|
|
testState.hooksConfig = {
|
|
enabled: true,
|
|
token: HOOK_TOKEN,
|
|
allowedAgentIds: [],
|
|
};
|
|
testState.agentsConfig = {
|
|
list: [{ id: "main", default: true }, { id: "hooks" }],
|
|
};
|
|
await withGatewayServer(async ({ port }) => {
|
|
const resDenied = await postHook(port, "/hooks/agent", {
|
|
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_TOKEN };
|
|
await withGatewayServer(async ({ port }) => {
|
|
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 postHook(port, "/hooks/wake", { text: "blocked" }, { token: "wrong" });
|
|
}
|
|
expect(throttled?.status).toBe(429);
|
|
expect(requireNonEmptyString(throttled?.headers.get("retry-after"), "retry-after")).toMatch(
|
|
/^\d+$/,
|
|
);
|
|
|
|
const allowed = await postHook(port, "/hooks/wake", { text: "auth reset" });
|
|
expect(allowed.status).toBe(200);
|
|
await waitForSystemEvent();
|
|
drainSystemEvents(resolveMainKey());
|
|
|
|
const failAfterSuccess = await postHook(
|
|
port,
|
|
"/hooks/wake",
|
|
{ text: "blocked" },
|
|
{ token: "wrong" },
|
|
);
|
|
expect(failAfterSuccess.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
test("rejects non-POST hook requests without consuming auth failure budget", async () => {
|
|
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
|
await withGatewayServer(async ({ port }) => {
|
|
let lastGet: Response | null = null;
|
|
for (let i = 0; i < 21; i++) {
|
|
lastGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
|
method: "GET",
|
|
headers: { Authorization: "Bearer wrong" },
|
|
});
|
|
}
|
|
expect(lastGet?.status).toBe(405);
|
|
expect(lastGet?.headers.get("allow")).toBe("POST");
|
|
|
|
const allowed = await postHook(port, "/hooks/wake", { text: "still works" });
|
|
expect(allowed.status).toBe(200);
|
|
await waitForSystemEvent();
|
|
drainSystemEvents(resolveMainKey());
|
|
});
|
|
});
|
|
});
|