diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index 5363c5e4defe..95038937cea2 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -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"); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 8422933006b1..ceb4e50d56b5 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -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 | undefined, + baseAction: HookAction | undefined, override: Exclude, ): 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 { diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 9cb5742c3cec..e6c7bbcde139 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -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", () => { diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index c3723af50f48..96c840da100d 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -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; } } diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 3c28b337e2dd..9f0537501931 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -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"); }); }); diff --git a/src/gateway/server/hooks-request-handler.ts b/src/gateway/server/hooks-request-handler.ts index 1f0e6ffb10be..96a026180823 100644 --- a/src/gateway/server/hooks-request-handler.ts +++ b/src/gateway/server/hooks-request-handler.ts @@ -48,7 +48,12 @@ export type HookClientIpConfig = Readonly<{ export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; 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; @@ -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; diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 58bb679a9a61..ba5a6493fa0c 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -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, + }); } };