Files
openclaw/src/gateway/hooks-mapping.test.ts
2026-02-14 13:46:09 +01:00

340 lines
9.9 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js";
const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail");
describe("hooks mapping", () => {
it("resolves gmail preset", () => {
const mappings = resolveHookMappings({ presets: ["gmail"] });
expect(mappings.length).toBeGreaterThan(0);
expect(mappings[0]?.matchPath).toBe("gmail");
});
it("renders template from payload", async () => {
const mappings = resolveHookMappings({
mappings: [
{
id: "demo",
match: { path: "gmail" },
action: "agent",
messageTemplate: "Subject: {{messages[0].subject}}",
},
],
});
const result = await applyHookMappings(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Subject: Hello");
}
});
it("passes model override from mapping", async () => {
const mappings = resolveHookMappings({
mappings: [
{
id: "demo",
match: { path: "gmail" },
action: "agent",
messageTemplate: "Subject: {{messages[0].subject}}",
model: "openai/gpt-4.1-mini",
},
],
});
const result = await applyHookMappings(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result?.ok).toBe(true);
if (result?.ok && result.action.kind === "agent") {
expect(result.action.model).toBe("openai/gpt-4.1-mini");
}
});
it("runs transform module", async () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
const modPath = path.join(transformsRoot, "transform.mjs");
const placeholder = "${payload.name}";
fs.writeFileSync(
modPath,
`export default ({ payload }) => ({ kind: "wake", text: \`Ping ${placeholder}\` });`,
);
const mappings = resolveHookMappings(
{
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
);
const result = await applyHookMappings(mappings, {
payload: { name: "Ada" },
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/custom"),
path: "custom",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action.kind).toBe("wake");
if (result.action.kind === "wake") {
expect(result.action.text).toBe("Ping Ada");
}
}
});
it("rejects transform module traversal outside transformsDir", () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-traversal-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
expect(() =>
resolveHookMappings(
{
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: "../evil.mjs" },
},
],
},
{ configDir },
),
).toThrow(/must be within/);
});
it("rejects absolute transform module path outside transformsDir", () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-abs-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
const outside = path.join(os.tmpdir(), "evil.mjs");
expect(() =>
resolveHookMappings(
{
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: outside },
},
],
},
{ configDir },
),
).toThrow(/must be within/);
});
it("rejects transformsDir traversal outside the transforms root", () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-trav-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
expect(() =>
resolveHookMappings(
{
transformsDir: "..",
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
),
).toThrow(/Hook transformsDir/);
});
it("rejects transformsDir absolute path outside the transforms root", () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-abs-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
expect(() =>
resolveHookMappings(
{
transformsDir: os.tmpdir(),
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
),
).toThrow(/Hook transformsDir/);
});
it("accepts transformsDir subdirectory within the transforms root", async () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-ok-"));
const transformsSubdir = path.join(configDir, "hooks", "transforms", "subdir");
fs.mkdirSync(transformsSubdir, { recursive: true });
fs.writeFileSync(path.join(transformsSubdir, "transform.mjs"), "export default () => null;");
const mappings = resolveHookMappings(
{
transformsDir: "subdir",
mappings: [
{
match: { path: "skip" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
);
const result = await applyHookMappings(mappings, {
payload: {},
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/skip"),
path: "skip",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action).toBeNull();
expect("skipped" in result).toBe(true);
}
});
it("treats null transform as a handled skip", async () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-skip-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
const modPath = path.join(transformsRoot, "transform.mjs");
fs.writeFileSync(modPath, "export default () => null;");
const mappings = resolveHookMappings(
{
mappings: [
{
match: { path: "skip" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
);
const result = await applyHookMappings(mappings, {
payload: {},
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/skip"),
path: "skip",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action).toBeNull();
expect("skipped" in result).toBe(true);
}
});
it("prefers explicit mappings over presets", async () => {
const mappings = resolveHookMappings({
presets: ["gmail"],
mappings: [
{
id: "override",
match: { path: "gmail" },
action: "agent",
messageTemplate: "Override subject: {{messages[0].subject}}",
},
],
});
const result = await applyHookMappings(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Override subject: Hello");
}
});
it("passes agentId from mapping", async () => {
const mappings = resolveHookMappings({
mappings: [
{
id: "hooks-agent",
match: { path: "gmail" },
action: "agent",
messageTemplate: "Subject: {{messages[0].subject}}",
agentId: "hooks",
},
],
});
const result = await applyHookMappings(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result?.ok).toBe(true);
if (result?.ok && result.action?.kind === "agent") {
expect(result.action.agentId).toBe("hooks");
}
});
it("agentId is undefined when not set", async () => {
const mappings = resolveHookMappings({
mappings: [
{
id: "no-agent",
match: { path: "gmail" },
action: "agent",
messageTemplate: "Subject: {{messages[0].subject}}",
},
],
});
const result = await applyHookMappings(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result?.ok).toBe(true);
if (result?.ok && result.action?.kind === "agent") {
expect(result.action.agentId).toBeUndefined();
}
});
it("rejects missing message", async () => {
const mappings = resolveHookMappings({
mappings: [{ match: { path: "noop" }, action: "agent" }],
});
const result = await applyHookMappings(mappings, {
payload: {},
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/noop"),
path: "noop",
});
expect(result?.ok).toBe(false);
});
});