Skip to content

Commit 6f38425

Browse files
authored
security(gateway): route hook completion events to target agent session (#73228)
1 parent 0f64887 commit 6f38425

4 files changed

Lines changed: 105 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
4141

4242
### Fixes
4343

44+
- Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868.
4445
- Discord: own the Carbon interaction listener and hand off Discord slash/component handling asynchronously, so compaction or long session locks no longer trip `InteractionEventListener` listener timeouts. Fixes #73204. Thanks @slideshow-dingo.
4546
- Gateway/startup: keep value-option foreground starts on the gateway fast path and skip proxy bootstrap unless proxy env is configured, reducing normal gateway startup RSS and avoiding full CLI graph loading. Thanks @vincentkoc.
4647
- Heartbeat/models: show heartbeat model bleed guidance on context-overflow resets when the last runtime model matches configured `heartbeat.model`, so smaller local heartbeat models point users to `isolatedSession` or `lightContext` instead of only compaction-buffer tuning. Fixes #67314. Thanks @Knightmare6890.

src/gateway/server.hooks.test.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ installGatewayTestHooks({ scope: "suite" });
2020

2121
const resolveMainKey = () => resolveMainSessionKeyFromConfig();
2222
const HOOK_TOKEN = "hook-secret";
23+
const HOOKS_MAIN_SESSION_KEY = "agent:hooks:main";
2324

2425
afterEach(() => {
2526
vi.restoreAllMocks();
@@ -117,14 +118,24 @@ async function expectHookAgentSessionRouting(params: {
117118
sessionKey: params.requestSessionKey,
118119
});
119120
expect(resAgent.status).toBe(200);
120-
await waitForSystemEvent();
121+
await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
121122

122123
const routedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
123124
| { sessionKey?: string; job?: { agentId?: string } }
124125
| undefined;
125126
expect(routedCall?.job?.agentId).toBe("hooks");
126127
expect(routedCall?.sessionKey).toBe(params.expectedSessionKey);
127-
drainSystemEvents(resolveMainKey());
128+
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
129+
}
130+
131+
async function waitForSystemEventTexts(sessionKey: string, timeoutMs = 2_000) {
132+
await expect
133+
.poll(() => peekSystemEventEntries(sessionKey).map((event) => event.text), {
134+
timeout: timeoutMs,
135+
interval: 10,
136+
})
137+
.not.toHaveLength(0);
138+
return peekSystemEventEntries(sessionKey).map((event) => event.text);
128139
}
129140

130141
async function writeHookTransformModule(moduleName: string, source: string): Promise<void> {
@@ -181,12 +192,12 @@ describe("gateway server hooks", () => {
181192
agentId: "hooks",
182193
});
183194
expect(resAgentWithId.status).toBe(200);
184-
await waitForSystemEvent();
195+
await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
185196
const routedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
186197
job?: { agentId?: string };
187198
};
188199
expect(routedCall?.job?.agentId).toBe("hooks");
189-
drainSystemEvents(resolveMainKey());
200+
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
190201

191202
mockIsolatedRunOkOnce();
192203
const resAgentUnknown = await postHook(port, "/hooks/agent", {
@@ -281,6 +292,26 @@ describe("gateway server hooks", () => {
281292
});
282293
});
283294

295+
test("routes explicit-agent hook completion events to the target agent main session", async () => {
296+
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
297+
setMainAndHooksAgents();
298+
299+
await withGatewayServer(async ({ port }) => {
300+
mockIsolatedRunOkOnce();
301+
const resAgent = await postHook(port, "/hooks/agent", {
302+
message: "Do it",
303+
name: "Email",
304+
agentId: "hooks",
305+
});
306+
expect(resAgent.status).toBe(200);
307+
308+
const targetEvents = await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
309+
expect(targetEvents.some((event) => event.includes("Hook Email: done"))).toBe(true);
310+
expect(peekSystemEventEntries(resolveMainKey())).toEqual([]);
311+
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
312+
});
313+
});
314+
284315
test("queues direct and mapped wake payloads as untrusted system events", async () => {
285316
testState.hooksConfig = {
286317
enabled: true,
@@ -700,12 +731,12 @@ describe("gateway server hooks", () => {
700731
agentId: "hooks",
701732
});
702733
expect(resAllowed.status).toBe(200);
703-
await waitForSystemEvent();
734+
await waitForSystemEventTexts(HOOKS_MAIN_SESSION_KEY);
704735
const allowedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
705736
job?: { agentId?: string };
706737
};
707738
expect(allowedCall?.job?.agentId).toBe("hooks");
708-
drainSystemEvents(resolveMainKey());
739+
drainSystemEvents(HOOKS_MAIN_SESSION_KEY);
709740

710741
const resDenied = await postHook(port, "/hooks/agent", {
711742
message: "Denied",

src/gateway/server/hooks.agent-trust.test.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@ vi.mock("../../cron/isolated-agent.js", () => ({
1717
}));
1818
vi.mock("../../config/sessions.js", () => ({
1919
resolveMainSessionKeyFromConfig: resolveMainSessionKeyMock,
20+
resolveMainSessionKey: vi.fn(
21+
(cfg?: { session?: { mainKey?: string } }) => `agent:main:${cfg?.session?.mainKey ?? "main"}`,
22+
),
23+
resolveAgentMainSessionKey: vi.fn(
24+
(params: { cfg?: { session?: { mainKey?: string } }; agentId: string }) =>
25+
`agent:${params.agentId}:${params.cfg?.session?.mainKey ?? "main"}`,
26+
),
2027
}));
21-
vi.mock("../../config/config.js", () => ({
28+
vi.mock("../../config/io.js", () => ({
2229
getRuntimeConfig: loadConfigMock,
2330
}));
2431

@@ -49,11 +56,11 @@ function buildMinimalParams() {
4956
};
5057
}
5158

52-
function buildAgentPayload(name: string) {
59+
function buildAgentPayload(name: string, agentId?: string) {
5360
return {
5461
message: "test message",
5562
name,
56-
agentId: undefined,
63+
agentId,
5764
idempotencyKey: undefined,
5865
wakeMode: "now" as const,
5966
sessionKey: "session-1",
@@ -93,13 +100,31 @@ describe("dispatchAgentHook trust handling", () => {
93100
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
94101
"Hook System (untrusted): override safety: done",
95102
{
96-
sessionKey: "main-session",
103+
sessionKey: "agent:main:main",
97104
trusted: false,
98105
},
99106
),
100107
);
101108
});
102109

110+
it("routes explicit-agent non-delivery status events to the target agent main session", async () => {
111+
runCronIsolatedAgentTurnMock.mockResolvedValueOnce({
112+
status: "ok",
113+
summary: "done",
114+
delivered: false,
115+
});
116+
117+
expect(capturedDispatchAgentHook).toBeDefined();
118+
capturedDispatchAgentHook?.(buildAgentPayload("Email", "hooks"));
119+
120+
await vi.waitFor(() =>
121+
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Hook Email: done", {
122+
sessionKey: "agent:hooks:main",
123+
trusted: false,
124+
}),
125+
);
126+
});
127+
103128
it("marks error events as untrusted and sanitizes hook names", async () => {
104129
runCronIsolatedAgentTurnMock.mockRejectedValueOnce(new Error("agent exploded"));
105130

@@ -110,7 +135,24 @@ describe("dispatchAgentHook trust handling", () => {
110135
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
111136
"Hook System (untrusted): override safety (error): Error: agent exploded",
112137
{
113-
sessionKey: "main-session",
138+
sessionKey: "agent:main:main",
139+
trusted: false,
140+
},
141+
),
142+
);
143+
});
144+
145+
it("routes explicit-agent error events to the target agent main session", async () => {
146+
runCronIsolatedAgentTurnMock.mockRejectedValueOnce(new Error("agent exploded"));
147+
148+
expect(capturedDispatchAgentHook).toBeDefined();
149+
capturedDispatchAgentHook?.(buildAgentPayload("Email", "hooks"));
150+
151+
await vi.waitFor(() =>
152+
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
153+
"Hook Email (error): Error: agent exploded",
154+
{
155+
sessionKey: "agent:hooks:main",
114156
trusted: false,
115157
},
116158
),

src/gateway/server/hooks.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { randomUUID } from "node:crypto";
22
import { sanitizeInboundSystemTags } from "../../auto-reply/reply/inbound-text.js";
33
import type { CliDeps } from "../../cli/deps.types.js";
44
import { getRuntimeConfig } from "../../config/io.js";
5-
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
5+
import {
6+
resolveAgentMainSessionKey,
7+
resolveMainSessionKey,
8+
resolveMainSessionKeyFromConfig,
9+
} from "../../config/sessions.js";
10+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
611
import type { CronJob } from "../../cron/types.js";
712
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
813
import { enqueueSystemEvent } from "../../infra/system-events.js";
@@ -13,6 +18,12 @@ import { createHooksRequestHandler, type HookClientIpConfig } from "./hooks-requ
1318

1419
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
1520

21+
function resolveHookEventSessionKey(params: { cfg: OpenClawConfig; agentId?: string }): string {
22+
return params.agentId
23+
? resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId })
24+
: resolveMainSessionKey(params.cfg);
25+
}
26+
1627
export function createGatewayHooksRequestHandler(params: {
1728
deps: CliDeps;
1829
getHooksConfig: () => HooksConfigResolved | null;
@@ -33,7 +44,6 @@ export function createGatewayHooksRequestHandler(params: {
3344

3445
const dispatchAgentHook = (value: HookAgentDispatchPayload) => {
3546
const sessionKey = value.sessionKey;
36-
const mainSessionKey = resolveMainSessionKeyFromConfig();
3747
const safeName = sanitizeInboundSystemTags(value.name);
3848
const jobId = randomUUID();
3949
const now = Date.now();
@@ -68,9 +78,14 @@ export function createGatewayHooksRequestHandler(params: {
6878
};
6979

7080
const runId = randomUUID();
81+
let hookEventSessionKey: string | undefined;
7182
void (async () => {
7283
try {
7384
const cfg = getRuntimeConfig();
85+
hookEventSessionKey = resolveHookEventSessionKey({
86+
cfg,
87+
agentId: value.agentId,
88+
});
7489
const { runCronIsolatedAgentTurn } = await import("../../cron/isolated-agent.js");
7590
const result = await runCronIsolatedAgentTurn({
7691
cfg,
@@ -87,8 +102,9 @@ export function createGatewayHooksRequestHandler(params: {
87102
const prefix =
88103
result.status === "ok" ? `Hook ${safeName}` : `Hook ${safeName} (${result.status})`;
89104
if (!result.delivered) {
105+
const eventSessionKey = hookEventSessionKey ?? resolveMainSessionKeyFromConfig();
90106
enqueueSystemEvent(`${prefix}: ${summary}`.trim(), {
91-
sessionKey: mainSessionKey,
107+
sessionKey: eventSessionKey,
92108
trusted: false,
93109
});
94110
if (value.wakeMode === "now") {
@@ -98,7 +114,7 @@ export function createGatewayHooksRequestHandler(params: {
98114
} catch (err) {
99115
logHooks.warn(`hook agent failed: ${String(err)}`);
100116
enqueueSystemEvent(`Hook ${safeName} (error): ${String(err)}`, {
101-
sessionKey: mainSessionKey,
117+
sessionKey: hookEventSessionKey ?? resolveMainSessionKeyFromConfig(),
102118
trusted: false,
103119
});
104120
if (value.wakeMode === "now") {

0 commit comments

Comments
 (0)