diff --git a/CHANGELOG.md b/CHANGELOG.md index a3906c2ebe1..a9794a1e8da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(gateway): enforce allowRequestSessionKey gate on template-rendered mapping sessionKeys. (#69381) Thanks @pgondhi987. - OpenAI/Responses: resolve `/think` levels against each GPT model's supported reasoning efforts so `/think off` no longer becomes high reasoning or sends unsupported `reasoning.effort: "none"` payloads. - Lobster/TaskFlow: allow managed approval resumes to use `approvalId` without a resume token, and persist that id in approval wait state. (#69559) Thanks @kirkluokun. - Plugins/startup: install bundled runtime dependencies into each plugin's own runtime directory, reuse source-checkout repair caches after rebuilds, and log only packages that were actually installed so repeated Gateway starts stay quiet once deps are present. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 38eb989846e..dd1c9343f59 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -3193,8 +3193,8 @@ See [Multiple Gateways](/gateway/multiple-gateways). path: "/hooks", maxBodyBytes: 262144, defaultSessionKey: "hook:ingress", - allowRequestSessionKey: false, - allowedSessionKeyPrefixes: ["hook:"], + allowRequestSessionKey: true, + allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"], allowedAgentIds: ["hooks", "main"], presets: ["gmail"], transformsDir: "~/.openclaw/hooks/transforms", @@ -3225,6 +3225,7 @@ Validation and safety notes: - `hooks.token` must be **distinct** from `gateway.auth.token`; reusing the Gateway token is rejected. - `hooks.path` cannot be `/`; use a dedicated subpath such as `/hooks`. - If `hooks.allowRequestSessionKey=true`, constrain `hooks.allowedSessionKeyPrefixes` (for example `["hook:"]`). +- If a mapping or preset uses a templated `sessionKey`, set `hooks.allowedSessionKeyPrefixes` and `hooks.allowRequestSessionKey=true`. Static mapping keys do not require that opt-in. **Endpoints:** @@ -3232,6 +3233,7 @@ Validation and safety notes: - `POST /hooks/agent` → `{ message, name?, agentId?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }` - `sessionKey` from request payload is accepted only when `hooks.allowRequestSessionKey=true` (default: `false`). - `POST /hooks/` → resolved via `hooks.mappings` + - Template-rendered mapping `sessionKey` values are treated as externally supplied and also require `hooks.allowRequestSessionKey=true`. @@ -3243,8 +3245,8 @@ Validation and safety notes: - `agentId` routes to a specific agent; unknown IDs fall back to default. - `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all). - `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`. -- `allowRequestSessionKey`: allow `/hooks/agent` callers to set `sessionKey` (default: `false`). -- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`. +- `allowRequestSessionKey`: allow `/hooks/agent` callers and template-driven mapping session keys to set `sessionKey` (default: `false`). +- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`. It becomes required when any mapping or preset uses a templated `sessionKey`. - `deliver: true` sends final reply to a channel; `channel` defaults to `last`. - `model` overrides LLM for this hook run (must be allowed if model catalog is set). @@ -3252,6 +3254,10 @@ Validation and safety notes: ### Gmail integration +- The built-in Gmail preset uses `sessionKey: "hook:gmail:{{messages[0].id}}"`. +- If you keep that per-message routing, set `hooks.allowRequestSessionKey: true` and constrain `hooks.allowedSessionKeyPrefixes` to match the Gmail namespace, for example `["hook:", "hook:gmail:"]`. +- If you need `hooks.allowRequestSessionKey: false`, override the preset with a static `sessionKey` instead of the templated default. + ```json5 { hooks: { diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index e97899b7ecd..f14142a97f1 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -144,6 +144,44 @@ describe("hooks mapping", () => { } }); + it("marks template-derived session keys as templated", async () => { + const result = await applyGmailMappings({ + mappings: [ + { + id: "templated-session-key", + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:{{messages[0].subject}}", + }, + ], + }); + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.sessionKey).toBe("hook:gmail:Hello"); + expect(result.action.sessionKeySource).toBe("templated"); + } + }); + + it("marks literal session keys as static", async () => { + const result = await applyGmailMappings({ + mappings: [ + { + id: "static-session-key", + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:static", + }, + ], + }); + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.sessionKey).toBe("hook:gmail:static"); + expect(result.action.sessionKeySource).toBe("static"); + } + }); + it("runs transform module", async () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-")); const transformsRoot = path.join(configDir, "hooks", "transforms"); @@ -182,6 +220,184 @@ describe("hooks mapping", () => { } }); + it("treats transform-provided session keys as templated by default", async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-xform-")); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + fs.mkdirSync(transformsRoot, { recursive: true }); + fs.writeFileSync( + path.join(transformsRoot, "transform.mjs"), + [ + "export default ({ payload }) => ({", + ' kind: "agent",', + ' message: "Transformed",', + " sessionKey: `hook:gmail:${payload.subject}`,", + "});", + ].join("\n"), + ); + + const mappings = resolveHookMappings( + { + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:static", + transform: { module: "transform.mjs" }, + }, + ], + }, + { configDir }, + ); + + const result = await applyHookMappings(mappings, { + payload: { subject: "external" }, + headers: {}, + url: baseUrl, + path: "gmail", + }); + + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.sessionKey).toBe("hook:gmail:external"); + expect(result.action.sessionKeySource).toBe("templated"); + } + }); + + it("uses transform-provided static session key source metadata", async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-static-")); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + fs.mkdirSync(transformsRoot, { recursive: true }); + fs.writeFileSync( + path.join(transformsRoot, "transform.mjs"), + [ + "export default () => ({", + ' kind: "agent",', + ' message: "Transformed",', + ' sessionKey: "hook:gmail:fixed",', + ' sessionKeySource: "static",', + "});", + ].join("\n"), + ); + + const mappings = resolveHookMappings( + { + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:{{messages[0].subject}}", + transform: { module: "transform.mjs" }, + }, + ], + }, + { configDir }, + ); + + const result = await applyHookMappings(mappings, { + payload: gmailPayload, + headers: {}, + url: baseUrl, + path: "gmail", + }); + + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.sessionKey).toBe("hook:gmail:fixed"); + expect(result.action.sessionKeySource).toBe("static"); + } + }); + + it("treats empty transform session keys as absent for source tracking", async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-empty-")); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + fs.mkdirSync(transformsRoot, { recursive: true }); + fs.writeFileSync( + path.join(transformsRoot, "transform.mjs"), + [ + "export default () => ({", + ' kind: "agent",', + ' message: "Transformed",', + ' sessionKey: "",', + ' sessionKeySource: "templated",', + "});", + ].join("\n"), + ); + + const mappings = resolveHookMappings( + { + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:{{messages[0].subject}}", + transform: { module: "transform.mjs" }, + }, + ], + }, + { configDir }, + ); + + const result = await applyHookMappings(mappings, { + payload: gmailPayload, + headers: {}, + url: baseUrl, + path: "gmail", + }); + + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.sessionKey).toBe(""); + expect(result.action.sessionKeySource).toBeUndefined(); + } + }); + + it("defaults invalid transform session key source metadata to templated", async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-sessionkey-invalid-")); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + fs.mkdirSync(transformsRoot, { recursive: true }); + fs.writeFileSync( + path.join(transformsRoot, "transform.mjs"), + [ + "export default () => ({", + ' kind: "agent",', + ' message: "Transformed",', + ' sessionKey: "hook:gmail:from-transform",', + ' sessionKeySource: "bogus",', + "});", + ].join("\n"), + ); + + const mappings = resolveHookMappings( + { + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + transform: { module: "transform.mjs" }, + }, + ], + }, + { configDir }, + ); + + const result = await applyHookMappings(mappings, { + payload: gmailPayload, + headers: {}, + url: baseUrl, + path: "gmail", + }); + + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.sessionKey).toBe("hook:gmail:from-transform"); + expect(result.action.sessionKeySource).toBe("templated"); + } + }); + 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"); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 27850008132..a7647bd94c7 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { CONFIG_PATH } from "../config/paths.js"; +import { resolveConfigPathCandidate } from "../config/paths.js"; import type { HookMappingConfig, HooksConfig } from "../config/types.hooks.js"; import { importFileModule, resolveFunctionModuleExport } from "../hooks/module-loader.js"; import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js"; @@ -52,6 +52,7 @@ export type HookAction = agentId?: string; wakeMode: "now" | "next-heartbeat"; sessionKey?: string; + sessionKeySource?: "static" | "templated"; deliver?: boolean; allowUnsafeExternalContent?: boolean; channel?: HookMessageChannel; @@ -61,6 +62,8 @@ export type HookAction = timeoutSeconds?: number; }; +export type HookSessionKeyTemplateSource = "static" | "templated"; + export type HookMappingResult = | { ok: true; action: HookAction } | { ok: true; action: null; skipped: true } @@ -92,6 +95,7 @@ type HookTransformResult = Partial<{ wakeMode: "now" | "next-heartbeat"; name: string; sessionKey: string; + sessionKeySource: HookSessionKeyTemplateSource; deliver: boolean; allowUnsafeExternalContent: boolean; channel: HookMessageChannel; @@ -135,7 +139,7 @@ export function resolveHookMappings( return []; } - const configDir = path.resolve(opts?.configDir ?? path.dirname(CONFIG_PATH)); + const configDir = path.resolve(opts?.configDir ?? path.dirname(resolveConfigPathCandidate())); const transformsRootDir = path.join(configDir, "hooks", "transforms"); const transformsDir = resolveOptionalContainedPath( transformsRootDir, @@ -263,6 +267,7 @@ function buildActionFromMapping( agentId: mapping.agentId, wakeMode: mapping.wakeMode ?? "now", sessionKey: renderOptional(mapping.sessionKey, ctx), + sessionKeySource: getSessionKeyTemplateSource(mapping.sessionKey), deliver: mapping.deliver, allowUnsafeExternalContent: mapping.allowUnsafeExternalContent, channel: mapping.channel, @@ -301,6 +306,7 @@ function mergeAction( name: override.name ?? baseAgent?.name, agentId: override.agentId ?? baseAgent?.agentId, sessionKey: override.sessionKey ?? baseAgent?.sessionKey, + sessionKeySource: resolveMergedSessionKeySource(baseAgent, override), deliver: typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver, allowUnsafeExternalContent: typeof override.allowUnsafeExternalContent === "boolean" @@ -327,6 +333,36 @@ function validateAction(action: HookAction): HookMappingResult { return { ok: true, action }; } +function getSessionKeyTemplateSource( + sessionKeyTemplate: string | undefined, +): HookSessionKeyTemplateSource | undefined { + const normalizedTemplate = normalizeOptionalString(sessionKeyTemplate); + if (!normalizedTemplate) { + return undefined; + } + return hasHookTemplateExpressions(normalizedTemplate) ? "templated" : "static"; +} + +function resolveMergedSessionKeySource( + baseAgent: Extract | undefined, + override: Exclude, +): HookSessionKeyTemplateSource | undefined { + if (typeof override.sessionKey === "string") { + const normalizedSessionKey = normalizeOptionalString(override.sessionKey); + if (!normalizedSessionKey) { + // Empty transform overrides behave like an absent sessionKey and fall + // through to the default/generated key path later in hook dispatch. + return undefined; + } + return override.sessionKeySource === "static" ? "static" : "templated"; + } + return baseAgent?.sessionKeySource; +} + +export function hasHookTemplateExpressions(template: string): boolean { + return /\{\{\s*[^}]+\s*\}\}/.test(template); +} + async function loadTransform(transform: HookMappingTransformResolved): Promise { const cacheKey = `${transform.modulePath}::${transform.exportName ?? "default"}`; const cached = transformCache.get(cacheKey); diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index b96c9573417..9e397f09722 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -277,12 +277,56 @@ describe("gateway hooks helpers", () => { const allowed = resolveHookSessionKey({ hooksConfig: resolved, - source: "mapping", + source: "mapping-static", sessionKey: "hook:gmail:1", }); expect(allowed).toEqual({ ok: true, value: "hook:gmail:1" }); }); + test("resolveHookSessionKey blocks templated mapping sessionKey when request overrides are disabled", () => { + const cfg = { + hooks: { + enabled: true, + token: "secret", + allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"], + }, + } as OpenClawConfig; + const resolved = resolveHooksConfig(cfg); + expect(resolved).not.toBeNull(); + if (!resolved) { + return; + } + + const denied = resolveHookSessionKey({ + hooksConfig: resolved, + source: "mapping-templated", + sessionKey: "hook:gmail:attacker", + }); + expect(denied.ok).toBe(false); + }); + + test("resolveHookSessionKey still allows static mapping sessionKey when request overrides are disabled", () => { + const cfg = { + hooks: { + enabled: true, + token: "secret", + allowedSessionKeyPrefixes: ["hook:", "hook:gmail:"], + }, + } as OpenClawConfig; + const resolved = resolveHooksConfig(cfg); + expect(resolved).not.toBeNull(); + if (!resolved) { + return; + } + + const allowed = resolveHookSessionKey({ + hooksConfig: resolved, + source: "mapping-static", + sessionKey: "hook:gmail:fixed", + }); + expect(allowed).toEqual({ ok: true, value: "hook:gmail:fixed" }); + }); + test("resolveHookSessionKey uses defaultSessionKey when request key is absent", () => { const cfg = { hooks: { @@ -346,6 +390,142 @@ describe("gateway hooks helpers", () => { "hooks.allowedSessionKeyPrefixes must include 'hook:' when hooks.defaultSessionKey is unset", ); }); + + test("resolveHooksConfig requires prefixes for templated mapping session keys", () => { + expect(() => + resolveHooksConfig({ + hooks: { + enabled: true, + token: "secret", + allowRequestSessionKey: true, + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:{{messages[0].id}}", + }, + ], + }, + } as OpenClawConfig), + ).toThrow( + "hooks.allowedSessionKeyPrefixes is required when a hook mapping sessionKey uses templates, even if hooks.allowRequestSessionKey=true", + ); + }); + + test("resolveHooksConfig allows a static explicit mapping to shadow the templated gmail preset", () => { + expect(() => + resolveHooksConfig({ + hooks: { + enabled: true, + token: "secret", + allowRequestSessionKey: false, + presets: ["gmail"], + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:static", + }, + ], + }, + } as OpenClawConfig), + ).not.toThrow(); + }); + + test("resolveHooksConfig allows a static catch-all mapping to shadow a later templated mapping", () => { + expect(() => + resolveHooksConfig({ + hooks: { + enabled: true, + token: "secret", + mappings: [ + { + action: "agent", + messageTemplate: "catch-all", + sessionKey: "hook:static", + }, + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:{{messages[0].id}}", + }, + ], + }, + } as OpenClawConfig), + ).not.toThrow(); + }); + + test("resolveHooksConfig ignores templated session keys on wake mappings", () => { + expect(() => + resolveHooksConfig({ + hooks: { + enabled: true, + token: "secret", + mappings: [ + { + match: { path: "wake" }, + action: "wake", + textTemplate: "ping", + sessionKey: "hook:wake:{{payload.id}}", + }, + ], + }, + } as OpenClawConfig), + ).not.toThrow(); + }); + + test("resolveHooksConfig treats '/' match.path as a catch-all for shadowing", () => { + expect(() => + resolveHooksConfig({ + hooks: { + enabled: true, + token: "secret", + mappings: [ + { + match: { path: "/" }, + action: "agent", + messageTemplate: "catch-all", + sessionKey: "hook:static", + }, + { + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:{{messages[0].id}}", + }, + ], + }, + } as OpenClawConfig), + ).not.toThrow(); + }); + + test("resolveHooksConfig treats empty match.source as a wildcard for shadowing", () => { + expect(() => + resolveHooksConfig({ + hooks: { + enabled: true, + token: "secret", + mappings: [ + { + match: { path: "gmail", source: "" }, + action: "agent", + messageTemplate: "catch-all source", + sessionKey: "hook:static", + }, + { + match: { path: "gmail", source: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + sessionKey: "hook:gmail:{{messages[0].id}}", + }, + ], + }, + } as OpenClawConfig), + ).not.toThrow(); + }); }); const emptyRegistry = createTestRegistry([]); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 90cb6da6ded..bd01dd9329e 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -11,7 +11,11 @@ import { normalizeOptionalString, } from "../shared/string-coerce.js"; import { normalizeMessageChannel } from "../utils/message-channel-core.js"; -import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; +import { + hasHookTemplateExpressions, + type HookMappingResolved, + resolveHookMappings, +} from "./hooks-mapping.js"; import { resolveAllowedAgentIds } from "./hooks-policy.js"; import type { HookMessageChannel } from "./hooks.types.js"; @@ -40,6 +44,8 @@ export type HookSessionPolicyResolved = { allowedSessionKeyPrefixes?: string[]; }; +export type HookSessionKeySource = "request" | "mapping-static" | "mapping-templated"; + export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | null { if (cfg.hooks?.enabled !== true) { return null; @@ -82,6 +88,11 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n "hooks.allowedSessionKeyPrefixes must include 'hook:' when hooks.defaultSessionKey is unset", ); } + if (hasEffectiveTemplatedHookSessionKeyMapping(mappings) && !allowedSessionKeyPrefixes) { + throw new Error( + "hooks.allowedSessionKeyPrefixes is required when a hook mapping sessionKey uses templates, even if hooks.allowRequestSessionKey=true", + ); + } return { basePath: trimmed, token, @@ -301,19 +312,22 @@ export function isHookAgentAllowed( export const getHookAgentPolicyError = () => "agentId is not allowed by hooks.allowedAgentIds"; export const getHookSessionKeyRequestPolicyError = () => - "sessionKey is disabled for external /hooks/agent payloads; set hooks.allowRequestSessionKey=true to enable"; + "sessionKey is disabled for externally supplied hook payload values; set hooks.allowRequestSessionKey=true to enable"; export const getHookSessionKeyPrefixError = (prefixes: string[]) => `sessionKey must start with one of: ${prefixes.join(", ")}`; export function resolveHookSessionKey(params: { hooksConfig: HooksConfigResolved; - source: "request" | "mapping"; + source: HookSessionKeySource; sessionKey?: string; idFactory?: () => string; }): { ok: true; value: string } | { ok: false; error: string } { const requested = resolveSessionKey(params.sessionKey); if (requested) { - if (params.source === "request" && !params.hooksConfig.sessionPolicy.allowRequestSessionKey) { + if ( + (params.source === "request" || params.source === "mapping-templated") && + !params.hooksConfig.sessionPolicy.allowRequestSessionKey + ) { return { ok: false, error: getHookSessionKeyRequestPolicyError() }; } const allowedPrefixes = params.hooksConfig.sessionPolicy.allowedSessionKeyPrefixes; @@ -336,6 +350,36 @@ export function resolveHookSessionKey(params: { return { ok: true, value: generated }; } +function hasTemplatedHookSessionKey(sessionKey: string | undefined): boolean { + return typeof sessionKey === "string" && hasHookTemplateExpressions(sessionKey); +} + +function hasEffectiveTemplatedHookSessionKeyMapping(mappings: HookMappingResolved[]): boolean { + const effectiveMappings: HookMappingResolved[] = []; + for (const mapping of mappings) { + if (isHookMappingShadowed(mapping, effectiveMappings)) { + continue; + } + effectiveMappings.push(mapping); + if (mapping.action === "agent" && hasTemplatedHookSessionKey(mapping.sessionKey)) { + return true; + } + } + return false; +} + +function isHookMappingShadowed( + mapping: HookMappingResolved, + earlierMappings: HookMappingResolved[], +): boolean { + return earlierMappings.some((earlier) => { + if (earlier.matchPath && earlier.matchPath !== mapping.matchPath) { + return false; + } + return !earlier.matchSource || earlier.matchSource === mapping.matchSource; + }); +} + export function normalizeHookDispatchSessionKey(params: { sessionKey: string; targetAgentId: string | undefined; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 439fe864aad..a76dad64b6f 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -757,7 +757,8 @@ export function createHooksRequestHandler( } const sessionKey = resolveHookSessionKey({ hooksConfig, - source: "mapping", + source: + mapped.action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated", sessionKey: mapped.action.sessionKey, }); if (!sessionKey.ok) { diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 9672922a92b..c46abb5150f 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -1,4 +1,5 @@ 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 { @@ -126,6 +127,14 @@ async function expectHookAgentSessionRouting(params: { drainSystemEvents(resolveMainKey()); } +async function writeHookTransformModule(moduleName: string, source: string): Promise { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + expect(configPath).toBeTruthy(); + 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 }; @@ -396,6 +405,89 @@ describe("gateway server hooks", () => { }); }); + test("enforces templated vs static mapping session keys on /hooks/", 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 = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as + | { sessionKey?: string } + | undefined; + expect(staticCall?.sessionKey).toBe("hook:gmail:fixed"); + drainSystemEvents(resolveMainKey()); + }); + }); + + test("treats malformed transform sessionKeySource as templated on /hooks/", 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,