mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(hooks): route mapped wake events to configured sessions (#116109)
Fixes #64556
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user