fix(gateway): enforce allowRequestSessionKey gate on template-rendered mapping sessionKeys (#69381)

* fix: address issue

* fix: address review feedback

* fix: finalize issue changes

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address build failures

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-04-21 10:12:10 +05:30
committed by GitHub
parent 6c15561120
commit 5275d008ed
8 changed files with 588 additions and 12 deletions

View File

@@ -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.

View File

@@ -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/<name>` → resolved via `hooks.mappings`
- Template-rendered mapping `sessionKey` values are treated as externally supplied and also require `hooks.allowRequestSessionKey=true`.
<Accordion title="Mapping details">
@@ -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: {

View File

@@ -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");

View File

@@ -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<HookAction, { kind: "agent" }> | undefined,
override: Exclude<HookTransformResult, null>,
): 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<HookTransformFn> {
const cacheKey = `${transform.modulePath}::${transform.exportName ?? "default"}`;
const cached = transformCache.get(cacheKey);

View File

@@ -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([]);

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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<void> {
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/<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 = (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/<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,