fix(hooks): route mapped wake events to configured sessions (#116109)

Fixes #64556
This commit is contained in:
Vincent Koc
2026-07-30 06:56:19 +08:00
committed by GitHub
parent daaaef95f0
commit fc95d7190c
7 changed files with 221 additions and 35 deletions
+80
View File
@@ -323,6 +323,86 @@ describe("hooks mapping", () => {
}
});
it("carries wake agent and session routing from mappings", async () => {
const mappings = resolveHookMappings({
mappings: [
{
id: "targeted-wake",
match: { path: "gmail" },
action: "wake",
textTemplate: "Subject: {{messages[0].subject}}",
agentId: "hooks",
sessionKey: "hook:gmail:{{messages[0].subject}}",
},
],
});
const result = await applyHookMappings(mappings, {
payload: gmailPayload,
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result?.ok).toBe(true);
if (result?.ok && result.action?.kind === "wake") {
expect(result.action).toMatchObject({
agentId: "hooks",
sessionKey: "hook:gmail:Hello",
sessionKeySource: "templated",
});
}
});
it.each(["wake", "agent"] as const)(
"rejects %s session key templates that render empty",
async (action) => {
const mappings = resolveHookMappings({
mappings: [
{
id: `empty-${action}-session-key`,
match: { path: "gmail" },
action,
...(action === "wake"
? { textTemplate: "Subject: {{messages[0].subject}}" }
: { messageTemplate: "Subject: {{messages[0].subject}}" }),
sessionKey: "{{messages[0].missing}}",
},
],
});
const result = await applyHookMappings(mappings, {
payload: gmailPayload,
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result).toEqual({
ok: false,
error: "hook mapping sessionKey template rendered empty",
});
},
);
it("rejects custom wake sessions that cannot be drained on the next heartbeat", async () => {
const result = await applyGmailMappings({
mappings: [
{
id: "deferred-targeted-wake",
match: { path: "gmail" },
action: "wake",
textTemplate: "Subject: {{messages[0].subject}}",
wakeMode: "next-heartbeat",
sessionKey: "hook:gmail:fixed",
},
],
});
expect(result).toEqual({
ok: false,
error: "hook mapping sessionKey requires wakeMode=now",
});
});
it("runs transform module", async () => {
const configDir = makeTempDir(hooksTempDirs, "openclaw-config-");
const transformsRoot = path.join(configDir, "hooks", "transforms");
+25 -3
View File
@@ -50,6 +50,9 @@ type HookAction =
kind: "wake";
text: string;
mode: "now" | "next-heartbeat";
agentId?: string;
sessionKey?: string;
sessionKeySource?: "static" | "templated";
}
| {
kind: "agent";
@@ -270,6 +273,9 @@ function buildActionFromMapping(
kind: "wake",
text,
mode: mapping.wakeMode ?? "now",
agentId: mapping.agentId,
sessionKey: renderOptional(mapping.sessionKey, ctx),
sessionKeySource: getSessionKeyTemplateSource(mapping.sessionKey),
},
};
}
@@ -309,7 +315,14 @@ function mergeAction(
const baseWake = base.kind === "wake" ? base : undefined;
const text = typeof override.text === "string" ? override.text : (baseWake?.text ?? "");
const mode = override.mode === "next-heartbeat" ? "next-heartbeat" : (baseWake?.mode ?? "now");
return validateAction({ kind: "wake", text, mode });
return validateAction({
kind: "wake",
text,
mode,
agentId: override.agentId ?? baseWake?.agentId,
sessionKey: override.sessionKey ?? baseWake?.sessionKey,
sessionKeySource: resolveMergedSessionKeySource(baseWake, override),
});
}
const baseAgent = base.kind === "agent" ? base : undefined;
const message =
@@ -339,10 +352,19 @@ function mergeAction(
}
function validateAction(action: HookAction): HookMappingResult {
if (action.sessionKeySource === "templated" && !action.sessionKey?.trim()) {
return { ok: false, error: "hook mapping sessionKey template rendered empty" };
}
if (action.kind === "wake") {
if (!action.text?.trim()) {
return { ok: false, error: "hook mapping requires text" };
}
if (action.mode === "next-heartbeat" && action.sessionKey) {
return {
ok: false,
error: "hook mapping sessionKey requires wakeMode=now",
};
}
return { ok: true, action };
}
if (!action.message?.trim()) {
@@ -365,7 +387,7 @@ function getSessionKeyTemplateSource(
}
function resolveMergedSessionKeySource(
baseAgent: Extract<HookAction, { kind: "agent" }> | undefined,
baseAction: HookAction | undefined,
override: Exclude<HookTransformResult, null>,
): HookSessionKeyTemplateSource | undefined {
if (typeof override.sessionKey === "string") {
@@ -377,7 +399,7 @@ function resolveMergedSessionKeySource(
}
return override.sessionKeySource === "static" ? "static" : "templated";
}
return baseAgent?.sessionKeySource;
return baseAction?.sessionKeySource;
}
export function hasHookTemplateExpressions(template: string): boolean {
+17 -21
View File
@@ -568,27 +568,23 @@ describe("gateway hooks helpers", () => {
expect(resolved.sessionPolicy.allowedSessionKeyPrefixes).toBeUndefined();
});
test("resolveHooksConfig ignores templated session keys on wake mappings", () => {
const resolved = resolveHooksConfigOrThrow({
hooks: {
enabled: true,
token: "secret",
mappings: [
{
match: { path: "wake" },
action: "wake",
textTemplate: "ping",
sessionKey: "hook:wake:{{payload.id}}",
},
],
},
} as OpenClawConfig);
expect(resolved.mappings).toHaveLength(1);
expect(resolved.mappings[0]?.action).toBe("wake");
expect(resolved.mappings[0]?.matchPath).toBe("wake");
expect(resolved.mappings[0]?.sessionKey).toBe("hook:wake:{{payload.id}}");
expect(resolved.sessionPolicy.allowedSessionKeyPrefixes).toBeUndefined();
test("resolveHooksConfig applies templated session-key policy to wake mappings", () => {
expect(() =>
resolveHooksConfigOrThrow({
hooks: {
enabled: true,
token: "secret",
mappings: [
{
match: { path: "wake" },
action: "wake",
textTemplate: "ping",
sessionKey: "hook:wake:{{payload.id}}",
},
],
},
} as OpenClawConfig),
).toThrow("hooks.allowedSessionKeyPrefixes is required");
});
test("resolveHooksConfig treats '/' match.path as a catch-all for shadowing", () => {
+1 -1
View File
@@ -464,7 +464,7 @@ function hasEffectiveTemplatedHookSessionKeyMapping(mappings: HookMappingResolve
continue;
}
effectiveMappings.push(mapping);
if (mapping.action === "agent" && hasTemplatedHookSessionKey(mapping.sessionKey)) {
if (hasTemplatedHookSessionKey(mapping.sessionKey)) {
return true;
}
}
+15 -3
View File
@@ -504,14 +504,19 @@ describe("gateway server hooks", () => {
testState.hooksConfig = {
enabled: true,
token: HOOK_TOKEN,
allowedAgentIds: ["hooks"],
allowedSessionKeyPrefixes: ["hook:"],
mappings: [
{
match: { path: "mapped-wake" },
action: "wake",
textTemplate: "Mapped wake: {{payload.subject}}",
agentId: "hooks",
sessionKey: "hook:wake:fixed",
},
],
};
setMainAndHooksAgents();
await withGatewayServer(async ({ port }) => {
const direct = await postHook(port, "/hooks/wake", { text: "Direct wake" });
@@ -524,11 +529,18 @@ describe("gateway server hooks", () => {
const mapped = await postHook(port, "/hooks/mapped-wake", { subject: "Email" });
expect(mapped.status).toBe(200);
await waitForSystemEvent(5_000);
const mappedEvents = peekSystemEventEntries(resolveMainKey());
await waitForSystemEventTexts("agent:hooks:hook:wake:fixed");
const mappedEvents = peekSystemEventEntries("agent:hooks:hook:wake:fixed");
expect(mappedEvents).toHaveLength(1);
expect(mappedEvents[0]?.text).toBe("Mapped wake: Email");
drainSystemEvents(resolveMainKey());
drainSystemEvents("agent:hooks:hook:wake:fixed");
});
testState.sessionConfig = { scope: "global" };
await withGatewayServer(async ({ port }) => {
expect((await postHook(port, "/hooks/mapped-wake", { subject: "Global" })).status).toBe(200);
await waitForSystemEventTexts("global");
expect(peekSystemEvents("global")).toContain("Mapped wake: Global");
});
});
+38 -4
View File
@@ -48,7 +48,12 @@ export type HookClientIpConfig = Readonly<{
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
type HookDispatchers = {
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
dispatchWakeHook: (value: {
text: string;
mode: "now" | "next-heartbeat";
agentId?: string;
sessionKey?: string;
}) => void;
dispatchAgentHook: (
value: HookAgentDispatchPayload,
) => HookAgentDispatchResult | Promise<HookAgentDispatchResult>;
@@ -437,11 +442,40 @@ export function createHooksRequestHandler(
return true;
}
if (mapped.action.kind === "wake") {
const action = mapped.action;
let targetAgentId: string | undefined;
let dispatchSessionKey: string | undefined;
if (action.agentId || action.sessionKey) {
if (!isHookAgentAllowed(hooksConfig, action.agentId)) {
sendJson(res, 400, { ok: false, error: getHookAgentPolicyError() });
return true;
}
targetAgentId = resolveEffectiveHookTargetAgentId(hooksConfig, action.agentId);
if (action.sessionKey) {
const sessionKey = resolveHookSessionKey({
hooksConfig,
source:
action.sessionKeySource === "static" ? "mapping-static" : "mapping-templated",
sessionKey: action.sessionKey,
});
if (!sessionKey.ok) {
sendJson(res, 400, { ok: false, error: sessionKey.error });
return true;
}
dispatchSessionKey =
resolveDispatchSessionKeyOrRespond(sessionKey.value, targetAgentId) ?? undefined;
if (!dispatchSessionKey) {
return true;
}
}
}
dispatchWakeHook({
text: mapped.action.text,
mode: mapped.action.mode,
text: action.text,
mode: action.mode,
...(targetAgentId ? { agentId: targetAgentId } : {}),
...(dispatchSessionKey ? { sessionKey: dispatchSessionKey } : {}),
});
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
sendJson(res, 200, { ok: true, mode: action.mode });
return true;
}
const action = mapped.action;
+45 -3
View File
@@ -10,6 +10,7 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { CliDeps } from "../../cli/deps.types.js";
import { getRuntimeConfig } from "../../config/io.js";
import {
canonicalizeMainSessionAlias,
resolveAgentMainSessionKey,
resolveMainSessionKey,
resolveMainSessionKeyFromConfig,
@@ -25,6 +26,7 @@ import { requestHeartbeat } from "../../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import type { createSubsystemLogger } from "../../logging/subsystem.js";
import { runWithGatewayIndependentRootWorkContinuation } from "../../process/gateway-work-admission.js";
import { toAgentStoreSessionKey } from "../../routing/session-key.js";
import type { HookAgentDispatchPayload, HooksConfigResolved } from "../hooks.js";
import {
createHooksRequestHandler,
@@ -174,13 +176,53 @@ export function createGatewayHooksRequestHandler(params: {
const loadIsolatedAgentModule = () =>
(isolatedAgentModulePromise ??= import("../../cron/isolated-agent.js"));
const dispatchWakeHook = (value: { text: string; mode: "now" | "next-heartbeat" }) => {
const sessionKey = resolveMainSessionKeyFromConfig();
const dispatchWakeHook = (value: {
text: string;
mode: "now" | "next-heartbeat";
agentId?: string;
sessionKey?: string;
}) => {
const targeted = Boolean(value.agentId || value.sessionKey);
// A targeted wake must enqueue and wake the same canonical store key;
// otherwise the heartbeat runs for one agent while its event waits elsewhere.
const target = targeted
? (() => {
const cfg = getRuntimeConfig();
const agentId = value.agentId ?? resolveDefaultAgentId(cfg);
if (cfg.session?.scope === "global") {
return {
eventSessionKey: "global",
heartbeatTarget: { agentId },
};
}
const eventSessionKey = canonicalizeMainSessionAlias({
cfg,
agentId,
sessionKey: value.sessionKey
? toAgentStoreSessionKey({
agentId,
requestKey: value.sessionKey,
mainKey: cfg.session?.mainKey,
})
: resolveAgentMainSessionKey({ cfg, agentId }),
});
return {
eventSessionKey,
heartbeatTarget: { agentId, sessionKey: eventSessionKey },
};
})()
: undefined;
const sessionKey = target?.eventSessionKey ?? resolveMainSessionKeyFromConfig();
enqueueSystemEvent(value.text, {
sessionKey,
});
if (value.mode === "now") {
requestHeartbeat({ source: "hook", intent: "immediate", reason: "hook:wake" });
requestHeartbeat({
source: "hook",
intent: "immediate",
reason: "hook:wake",
...target?.heartbeatTarget,
});
}
};