517 lines
16 KiB
TypeScript
517 lines
16 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", () => {
|
|
const gmailPayload = { messages: [{ subject: "Hello" }] };
|
|
|
|
function expectSkippedTransformResult(result: Awaited<ReturnType<typeof applyHookMappings>>) {
|
|
expect(result?.ok).toBe(true);
|
|
if (result?.ok) {
|
|
expect(result.action).toBeNull();
|
|
expect("skipped" in result).toBe(true);
|
|
}
|
|
}
|
|
|
|
function createGmailAgentMapping(params: {
|
|
id: string;
|
|
messageTemplate: string;
|
|
model?: string;
|
|
agentId?: string;
|
|
}) {
|
|
return {
|
|
id: params.id,
|
|
match: { path: "gmail" },
|
|
action: "agent" as const,
|
|
messageTemplate: params.messageTemplate,
|
|
...(params.model ? { model: params.model } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
};
|
|
}
|
|
|
|
async function applyGmailMappings(config: Parameters<typeof resolveHookMappings>[0]) {
|
|
const mappings = resolveHookMappings(config);
|
|
return applyHookMappings(mappings, {
|
|
payload: gmailPayload,
|
|
headers: {},
|
|
url: baseUrl,
|
|
path: "gmail",
|
|
});
|
|
}
|
|
|
|
function expectAgentMessage(
|
|
result: Awaited<ReturnType<typeof applyHookMappings>> | undefined,
|
|
expectedMessage: string,
|
|
) {
|
|
expect(result?.ok).toBe(true);
|
|
if (result?.ok && result.action?.kind === "agent") {
|
|
expect(result.action.kind).toBe("agent");
|
|
expect(result.action.message).toBe(expectedMessage);
|
|
}
|
|
}
|
|
|
|
async function expectBlockedPrototypeTraversal(params: {
|
|
id: string;
|
|
messageTemplate: string;
|
|
payload: Record<string, unknown>;
|
|
expectedMessage: string;
|
|
}) {
|
|
const mappings = resolveHookMappings({
|
|
mappings: [
|
|
createGmailAgentMapping({
|
|
id: params.id,
|
|
messageTemplate: params.messageTemplate,
|
|
}),
|
|
],
|
|
});
|
|
const result = await applyHookMappings(mappings, {
|
|
payload: params.payload,
|
|
headers: {},
|
|
url: baseUrl,
|
|
path: "gmail",
|
|
});
|
|
expectAgentMessage(result, params.expectedMessage);
|
|
}
|
|
|
|
async function applyNullTransformFromTempConfig(params: {
|
|
configDir: string;
|
|
transformsDir?: string;
|
|
}) {
|
|
const transformsRoot = path.join(params.configDir, "hooks", "transforms");
|
|
const transformsDir = params.transformsDir
|
|
? path.join(transformsRoot, params.transformsDir)
|
|
: transformsRoot;
|
|
fs.mkdirSync(transformsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(transformsDir, "transform.mjs"), "export default () => null;");
|
|
|
|
const mappings = resolveHookMappings(
|
|
{
|
|
transformsDir: params.transformsDir,
|
|
mappings: [
|
|
{
|
|
match: { path: "skip" },
|
|
action: "agent",
|
|
transform: { module: "transform.mjs" },
|
|
},
|
|
],
|
|
},
|
|
{ configDir: params.configDir },
|
|
);
|
|
|
|
return applyHookMappings(mappings, {
|
|
payload: {},
|
|
headers: {},
|
|
url: new URL("http://127.0.0.1:18789/hooks/skip"),
|
|
path: "skip",
|
|
});
|
|
}
|
|
|
|
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 result = await applyGmailMappings({
|
|
mappings: [
|
|
createGmailAgentMapping({
|
|
id: "demo",
|
|
messageTemplate: "Subject: {{messages[0].subject}}",
|
|
}),
|
|
],
|
|
});
|
|
expectAgentMessage(result, "Subject: Hello");
|
|
});
|
|
|
|
it("passes model override from mapping", async () => {
|
|
const result = await applyGmailMappings({
|
|
mappings: [
|
|
createGmailAgentMapping({
|
|
id: "demo",
|
|
messageTemplate: "Subject: {{messages[0].subject}}",
|
|
model: "openai/gpt-4.1-mini",
|
|
}),
|
|
],
|
|
});
|
|
expect(result?.ok).toBe(true);
|
|
if (result?.ok && result.action && 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 && result.action?.kind === "wake") {
|
|
expect(result.action.kind).toBe("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 result = await applyNullTransformFromTempConfig({ configDir, transformsDir: "subdir" });
|
|
expectSkippedTransformResult(result);
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects transform module symlink escape outside transformsDir",
|
|
() => {
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-symlink-module-"));
|
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-outside-module-"));
|
|
const outsideModule = path.join(outsideDir, "evil.mjs");
|
|
fs.writeFileSync(outsideModule, 'export default () => ({ kind: "wake", text: "owned" });');
|
|
fs.symlinkSync(outsideModule, path.join(transformsRoot, "linked.mjs"));
|
|
expect(() =>
|
|
resolveHookMappings(
|
|
{
|
|
mappings: [
|
|
{
|
|
match: { path: "custom" },
|
|
action: "agent",
|
|
transform: { module: "linked.mjs" },
|
|
},
|
|
],
|
|
},
|
|
{ configDir },
|
|
),
|
|
).toThrow(/must be within/);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects transformsDir symlink escape outside transforms root",
|
|
() => {
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-symlink-dir-"));
|
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-outside-dir-"));
|
|
fs.writeFileSync(path.join(outsideDir, "transform.mjs"), "export default () => null;");
|
|
fs.symlinkSync(outsideDir, path.join(transformsRoot, "escape"), "dir");
|
|
expect(() =>
|
|
resolveHookMappings(
|
|
{
|
|
transformsDir: "escape",
|
|
mappings: [
|
|
{
|
|
match: { path: "custom" },
|
|
action: "agent",
|
|
transform: { module: "transform.mjs" },
|
|
},
|
|
],
|
|
},
|
|
{ configDir },
|
|
),
|
|
).toThrow(/Hook transformsDir/);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")("accepts in-root transform module symlink", async () => {
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-symlink-ok-"));
|
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
|
const nestedDir = path.join(transformsRoot, "nested");
|
|
fs.mkdirSync(nestedDir, { recursive: true });
|
|
fs.writeFileSync(path.join(nestedDir, "transform.mjs"), "export default () => null;");
|
|
fs.symlinkSync(path.join(nestedDir, "transform.mjs"), path.join(transformsRoot, "linked.mjs"));
|
|
|
|
const mappings = resolveHookMappings(
|
|
{
|
|
mappings: [
|
|
{
|
|
match: { path: "skip" },
|
|
action: "agent",
|
|
transform: { module: "linked.mjs" },
|
|
},
|
|
],
|
|
},
|
|
{ configDir },
|
|
);
|
|
|
|
const result = await applyHookMappings(mappings, {
|
|
payload: {},
|
|
headers: {},
|
|
url: new URL("http://127.0.0.1:18789/hooks/skip"),
|
|
path: "skip",
|
|
});
|
|
|
|
expectSkippedTransformResult(result);
|
|
});
|
|
|
|
it("treats null transform as a handled skip", async () => {
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-skip-"));
|
|
const result = await applyNullTransformFromTempConfig({ configDir });
|
|
expectSkippedTransformResult(result);
|
|
});
|
|
|
|
it("prefers explicit mappings over presets", async () => {
|
|
const result = await applyGmailMappings({
|
|
presets: ["gmail"],
|
|
mappings: [
|
|
createGmailAgentMapping({
|
|
id: "override",
|
|
messageTemplate: "Override subject: {{messages[0].subject}}",
|
|
}),
|
|
],
|
|
});
|
|
expectAgentMessage(result, "Override subject: Hello");
|
|
});
|
|
|
|
it("passes agentId from mapping", async () => {
|
|
const result = await applyGmailMappings({
|
|
mappings: [
|
|
createGmailAgentMapping({
|
|
id: "hooks-agent",
|
|
messageTemplate: "Subject: {{messages[0].subject}}",
|
|
agentId: "hooks",
|
|
}),
|
|
],
|
|
});
|
|
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 result = await applyGmailMappings({
|
|
mappings: [
|
|
createGmailAgentMapping({
|
|
id: "no-agent",
|
|
messageTemplate: "Subject: {{messages[0].subject}}",
|
|
}),
|
|
],
|
|
});
|
|
expect(result?.ok).toBe(true);
|
|
if (result?.ok && result.action?.kind === "agent") {
|
|
expect(result.action.agentId).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("caches transform functions by module path and export name", async () => {
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-export-"));
|
|
const transformsRoot = path.join(configDir, "hooks", "transforms");
|
|
fs.mkdirSync(transformsRoot, { recursive: true });
|
|
const modPath = path.join(transformsRoot, "multi-export.mjs");
|
|
fs.writeFileSync(
|
|
modPath,
|
|
[
|
|
'export function transformA() { return { kind: "wake", text: "from-A" }; }',
|
|
'export function transformB() { return { kind: "wake", text: "from-B" }; }',
|
|
].join("\n"),
|
|
);
|
|
|
|
const mappingsA = resolveHookMappings(
|
|
{
|
|
mappings: [
|
|
{
|
|
match: { path: "testA" },
|
|
action: "agent",
|
|
messageTemplate: "unused",
|
|
transform: { module: "multi-export.mjs", export: "transformA" },
|
|
},
|
|
],
|
|
},
|
|
{ configDir },
|
|
);
|
|
|
|
const mappingsB = resolveHookMappings(
|
|
{
|
|
mappings: [
|
|
{
|
|
match: { path: "testB" },
|
|
action: "agent",
|
|
messageTemplate: "unused",
|
|
transform: { module: "multi-export.mjs", export: "transformB" },
|
|
},
|
|
],
|
|
},
|
|
{ configDir },
|
|
);
|
|
|
|
const resultA = await applyHookMappings(mappingsA, {
|
|
payload: {},
|
|
headers: {},
|
|
url: new URL("http://127.0.0.1:18789/hooks/testA"),
|
|
path: "testA",
|
|
});
|
|
|
|
const resultB = await applyHookMappings(mappingsB, {
|
|
payload: {},
|
|
headers: {},
|
|
url: new URL("http://127.0.0.1:18789/hooks/testB"),
|
|
path: "testB",
|
|
});
|
|
|
|
expect(resultA?.ok).toBe(true);
|
|
if (resultA?.ok && resultA.action?.kind === "wake") {
|
|
expect(resultA.action.text).toBe("from-A");
|
|
}
|
|
|
|
expect(resultB?.ok).toBe(true);
|
|
if (resultB?.ok && resultB.action?.kind === "wake") {
|
|
expect(resultB.action.text).toBe("from-B");
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
describe("prototype pollution protection", () => {
|
|
it("blocks __proto__ traversal in webhook payload", async () => {
|
|
await expectBlockedPrototypeTraversal({
|
|
id: "proto-test",
|
|
messageTemplate: "value: {{__proto__}}",
|
|
payload: { __proto__: { polluted: true } } as Record<string, unknown>,
|
|
expectedMessage: "value: ",
|
|
});
|
|
});
|
|
|
|
it("blocks constructor traversal in webhook payload", async () => {
|
|
await expectBlockedPrototypeTraversal({
|
|
id: "constructor-test",
|
|
messageTemplate: "type: {{constructor.name}}",
|
|
payload: { constructor: { name: "INJECTED" } } as Record<string, unknown>,
|
|
expectedMessage: "type: ",
|
|
});
|
|
});
|
|
|
|
it("blocks prototype traversal in webhook payload", async () => {
|
|
await expectBlockedPrototypeTraversal({
|
|
id: "prototype-test",
|
|
messageTemplate: "val: {{prototype}}",
|
|
payload: { prototype: "leaked" } as Record<string, unknown>,
|
|
expectedMessage: "val: ",
|
|
});
|
|
});
|
|
});
|
|
});
|