import type { IncomingMessage } from "node:http"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { extractHookToken, isHookAgentAllowed, resolveHookSessionKey, resolveHookTargetAgentId, normalizeAgentPayload, normalizeWakePayload, resolveHooksConfig, } from "./hooks.js"; describe("gateway hooks helpers", () => { const resolveHooksConfigOrThrow = (cfg: OpenClawConfig) => { const resolved = resolveHooksConfig(cfg); expect(resolved).not.toBeNull(); if (!resolved) { throw new Error("hooks config missing"); } return resolved; }; const buildHookAgentConfig = (allowedAgentIds: string[]) => ({ hooks: { enabled: true, token: "secret", allowedAgentIds, }, agents: { list: [{ id: "main", default: true }, { id: "hooks" }], }, }) as OpenClawConfig; beforeEach(() => { setActivePluginRegistry(emptyRegistry); }); afterEach(() => { setActivePluginRegistry(emptyRegistry); }); test("resolveHooksConfig normalizes paths + requires token", () => { const base = { hooks: { enabled: true, token: "secret", path: "hooks///", }, } as OpenClawConfig; const resolved = resolveHooksConfig(base); expect(resolved?.basePath).toBe("/hooks"); expect(resolved?.token).toBe("secret"); expect(resolved?.sessionPolicy.allowRequestSessionKey).toBe(false); }); test("resolveHooksConfig rejects root path", () => { const cfg = { hooks: { enabled: true, token: "x", path: "/" }, } as OpenClawConfig; expect(() => resolveHooksConfig(cfg)).toThrow("hooks.path may not be '/'"); }); test("extractHookToken prefers bearer > header", () => { const req = { headers: { authorization: "Bearer top", "x-openclaw-token": "header", }, } as unknown as IncomingMessage; const result1 = extractHookToken(req); expect(result1).toBe("top"); const req2 = { headers: { "x-openclaw-token": "header" }, } as unknown as IncomingMessage; const result2 = extractHookToken(req2); expect(result2).toBe("header"); const req3 = { headers: {} } as unknown as IncomingMessage; const result3 = extractHookToken(req3); expect(result3).toBeUndefined(); }); test("normalizeWakePayload trims + validates", () => { expect(normalizeWakePayload({ text: " hi " })).toEqual({ ok: true, value: { text: "hi", mode: "now" }, }); expect(normalizeWakePayload({ text: " ", mode: "now" }).ok).toBe(false); }); test("normalizeAgentPayload defaults + validates channel", () => { const ok = normalizeAgentPayload({ message: "hello" }); expect(ok.ok).toBe(true); if (ok.ok) { expect(ok.value.sessionKey).toBeUndefined(); expect(ok.value.channel).toBe("last"); expect(ok.value.name).toBe("Hook"); expect(ok.value.deliver).toBe(true); } const explicitNoDeliver = normalizeAgentPayload({ message: "hello", deliver: false }); expect(explicitNoDeliver.ok).toBe(true); if (explicitNoDeliver.ok) { expect(explicitNoDeliver.value.deliver).toBe(false); } setActivePluginRegistry( createTestRegistry([ { pluginId: "imessage", source: "test", plugin: createIMessageTestPlugin(), }, ]), ); const imsg = normalizeAgentPayload({ message: "yo", channel: "imsg" }); expect(imsg.ok).toBe(true); if (imsg.ok) { expect(imsg.value.channel).toBe("imessage"); } setActivePluginRegistry( createTestRegistry([ { pluginId: "msteams", source: "test", plugin: createMSTeamsPlugin({ aliases: ["teams"] }), }, ]), ); const teams = normalizeAgentPayload({ message: "yo", channel: "teams" }); expect(teams.ok).toBe(true); if (teams.ok) { expect(teams.value.channel).toBe("msteams"); } const bad = normalizeAgentPayload({ message: "yo", channel: "sms" }); expect(bad.ok).toBe(false); }); test("normalizeAgentPayload passes agentId", () => { const ok = normalizeAgentPayload({ message: "hello", agentId: "hooks" }); expect(ok.ok).toBe(true); if (ok.ok) { expect(ok.value.agentId).toBe("hooks"); } const noAgent = normalizeAgentPayload({ message: "hello" }); expect(noAgent.ok).toBe(true); if (noAgent.ok) { expect(noAgent.value.agentId).toBeUndefined(); } }); test("resolveHookTargetAgentId falls back to default for unknown agent ids", () => { const cfg = { hooks: { enabled: true, token: "secret" }, agents: { list: [{ id: "main", default: true }, { id: "hooks" }], }, } as OpenClawConfig; const resolved = resolveHooksConfig(cfg); expect(resolved).not.toBeNull(); if (!resolved) { return; } expect(resolveHookTargetAgentId(resolved, "hooks")).toBe("hooks"); expect(resolveHookTargetAgentId(resolved, "missing-agent")).toBe("main"); expect(resolveHookTargetAgentId(resolved, undefined)).toBeUndefined(); }); test("isHookAgentAllowed honors hooks.allowedAgentIds for explicit routing", () => { const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig(["hooks"])); expect(isHookAgentAllowed(resolved, undefined)).toBe(true); expect(isHookAgentAllowed(resolved, "hooks")).toBe(true); expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(false); }); test("isHookAgentAllowed treats empty allowlist as deny-all for explicit agentId", () => { const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig([])); expect(isHookAgentAllowed(resolved, undefined)).toBe(true); expect(isHookAgentAllowed(resolved, "hooks")).toBe(false); expect(isHookAgentAllowed(resolved, "main")).toBe(false); }); test("isHookAgentAllowed treats wildcard allowlist as allow-all", () => { const resolved = resolveHooksConfigOrThrow(buildHookAgentConfig(["*"])); expect(isHookAgentAllowed(resolved, undefined)).toBe(true); expect(isHookAgentAllowed(resolved, "hooks")).toBe(true); expect(isHookAgentAllowed(resolved, "missing-agent")).toBe(true); }); test("resolveHookSessionKey disables request sessionKey by default", () => { const cfg = { hooks: { enabled: true, token: "secret" }, } as OpenClawConfig; const resolved = resolveHooksConfig(cfg); expect(resolved).not.toBeNull(); if (!resolved) { return; } const denied = resolveHookSessionKey({ hooksConfig: resolved, source: "request", sessionKey: "agent:main:dm:u99999", }); expect(denied.ok).toBe(false); }); test("resolveHookSessionKey allows request sessionKey when explicitly enabled", () => { const cfg = { hooks: { enabled: true, token: "secret", allowRequestSessionKey: true }, } as OpenClawConfig; const resolved = resolveHooksConfig(cfg); expect(resolved).not.toBeNull(); if (!resolved) { return; } const allowed = resolveHookSessionKey({ hooksConfig: resolved, source: "request", sessionKey: "hook:manual", }); expect(allowed).toEqual({ ok: true, value: "hook:manual" }); }); test("resolveHookSessionKey enforces allowed prefixes", () => { const cfg = { hooks: { enabled: true, token: "secret", allowRequestSessionKey: true, allowedSessionKeyPrefixes: ["hook:"], }, } as OpenClawConfig; const resolved = resolveHooksConfig(cfg); expect(resolved).not.toBeNull(); if (!resolved) { return; } const blocked = resolveHookSessionKey({ hooksConfig: resolved, source: "request", sessionKey: "agent:main:main", }); expect(blocked.ok).toBe(false); const allowed = resolveHookSessionKey({ hooksConfig: resolved, source: "mapping", sessionKey: "hook:gmail:1", }); expect(allowed).toEqual({ ok: true, value: "hook:gmail:1" }); }); test("resolveHookSessionKey uses defaultSessionKey when request key is absent", () => { const cfg = { hooks: { enabled: true, token: "secret", defaultSessionKey: "hook:ingress", }, } as OpenClawConfig; const resolved = resolveHooksConfig(cfg); expect(resolved).not.toBeNull(); if (!resolved) { return; } const resolvedKey = resolveHookSessionKey({ hooksConfig: resolved, source: "request", }); expect(resolvedKey).toEqual({ ok: true, value: "hook:ingress" }); }); test("resolveHooksConfig validates defaultSessionKey and generated fallback against prefixes", () => { expect(() => resolveHooksConfig({ hooks: { enabled: true, token: "secret", defaultSessionKey: "agent:main:main", allowedSessionKeyPrefixes: ["hook:"], }, } as OpenClawConfig), ).toThrow("hooks.defaultSessionKey must match hooks.allowedSessionKeyPrefixes"); expect(() => resolveHooksConfig({ hooks: { enabled: true, token: "secret", allowedSessionKeyPrefixes: ["agent:"], }, } as OpenClawConfig), ).toThrow( "hooks.allowedSessionKeyPrefixes must include 'hook:' when hooks.defaultSessionKey is unset", ); }); }); const emptyRegistry = createTestRegistry([]); const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => ({ id: "msteams", meta: { id: "msteams", label: "Microsoft Teams", selectionLabel: "Microsoft Teams (Bot Framework)", docsPath: "/channels/msteams", blurb: "Bot Framework; enterprise support.", aliases: params.aliases, }, capabilities: { chatTypes: ["direct"] }, config: { listAccountIds: () => [], resolveAccount: () => ({}), }, });