mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:40:44 +00:00
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:
committed by
GitHub
parent
6c15561120
commit
5275d008ed
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user