Skip to content

Commit 2f31184

Browse files
fix(hooks): repair shared-hook announcement policy (#73800)
* fix(hooks): repair shared-hook announcement policy * fix(hooks): audit suppressed hook successes --------- Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Co-authored-by: Vincent Koc <[email protected]>
1 parent cf43b92 commit 2f31184

7 files changed

Lines changed: 195 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai
7777
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
7878
- Plugins/providers: keep Gateway startup primary-model discovery on metadata-only provider entries and reuse active non-speech capability providers even with explicit plugin entries, avoiding unnecessary provider registry loads during startup and media capability checks. Fixes #73729, #73835, and #73793; carries forward #73853 and #73794. Thanks @sg1416-zg, @brokemac79, and @poolside-ventures.
7979
- Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash.
80+
- Gateway/hooks: keep successful `deliver:false` agent hooks silent, log a hook audit record for suppressed success announcements, and suppress fallback summaries after attempted hook delivery while still surfacing failed hook runs. Repairs #55761; builds on #36332 and #49234. Thanks @EffortlessSteven, @cioclawcode, and @BrennerSpear.
8081
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/[email protected]` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
8182
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
8283
- Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX.

src/gateway/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export type HookAgentPayload = {
227227

228228
export type HookAgentDispatchPayload = Omit<HookAgentPayload, "sessionKey"> & {
229229
sessionKey: string;
230+
sourcePath: string;
230231
allowUnsafeExternalContent?: boolean;
231232
externalContentSource?: HookExternalContentSource;
232233
};

src/gateway/server.hooks.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ function mockIsolatedRunOk(): void {
7676
});
7777
}
7878

79+
async function waitForCronIsolatedRuns(count: number, timeoutMs = 2_000): Promise<void> {
80+
await expect
81+
.poll(() => cronIsolatedRun.mock.calls.length, { timeout: timeoutMs, interval: 10 })
82+
.toBe(count);
83+
}
84+
7985
async function postAgentHookWithIdempotency(
8086
port: number,
8187
idempotencyKey: string,
@@ -312,6 +318,86 @@ describe("gateway server hooks", () => {
312318
});
313319
});
314320

321+
test("hook announcement policy keeps no-deliver success silent without hiding failures", async () => {
322+
testState.hooksConfig = {
323+
enabled: true,
324+
token: HOOK_TOKEN,
325+
mappings: [
326+
{
327+
match: { path: "mapped-silent" },
328+
action: "agent",
329+
messageTemplate: "Mapped: {{payload.subject}}",
330+
deliver: false,
331+
},
332+
],
333+
};
334+
setMainAndHooksAgents();
335+
336+
await withGatewayServer(async ({ port }) => {
337+
cronIsolatedRun.mockClear();
338+
cronIsolatedRun.mockResolvedValueOnce({
339+
status: "ok",
340+
summary: "done",
341+
delivered: false,
342+
});
343+
const directSilent = await postHook(port, "/hooks/agent", {
344+
message: "Do it",
345+
name: "Email",
346+
deliver: false,
347+
});
348+
expect(directSilent.status).toBe(200);
349+
await waitForCronIsolatedRuns(1);
350+
expect(peekSystemEventEntries(resolveMainKey())).toEqual([]);
351+
352+
cronIsolatedRun.mockResolvedValueOnce({
353+
status: "ok",
354+
summary: "mapped done",
355+
delivered: false,
356+
});
357+
const mappedSilent = await postHook(port, "/hooks/mapped-silent", { subject: "Email" });
358+
expect(mappedSilent.status).toBe(200);
359+
await waitForCronIsolatedRuns(2);
360+
expect(peekSystemEventEntries(resolveMainKey())).toEqual([]);
361+
362+
cronIsolatedRun.mockResolvedValueOnce({
363+
status: "error",
364+
summary: "boom",
365+
delivered: false,
366+
});
367+
const directFailure = await postHook(port, "/hooks/agent", {
368+
message: "Do it",
369+
name: "Email",
370+
deliver: false,
371+
});
372+
expect(directFailure.status).toBe(200);
373+
const failureEvents = await waitForSystemEventTexts(resolveMainKey());
374+
expect(failureEvents).toContain("Hook Email (error): boom");
375+
drainSystemEvents(resolveMainKey());
376+
});
377+
});
378+
379+
test("hook announcement policy suppresses fallback after attempted delivery", async () => {
380+
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
381+
setMainAndHooksAgents();
382+
383+
await withGatewayServer(async ({ port }) => {
384+
cronIsolatedRun.mockClear();
385+
cronIsolatedRun.mockResolvedValueOnce({
386+
status: "ok",
387+
summary: "done",
388+
delivered: false,
389+
deliveryAttempted: true,
390+
});
391+
const response = await postHook(port, "/hooks/agent", {
392+
message: "Do it",
393+
name: "Email",
394+
});
395+
expect(response.status).toBe(200);
396+
await waitForCronIsolatedRuns(1);
397+
expect(peekSystemEventEntries(resolveMainKey())).toEqual([]);
398+
});
399+
});
400+
315401
test("queues direct and mapped wake payloads as untrusted system events", async () => {
316402
testState.hooksConfig = {
317403
enabled: true,

src/gateway/server/hooks-request-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ export function createHooksRequestHandler(
321321
...normalized.value,
322322
idempotencyKey,
323323
sessionKey: normalizedDispatchSessionKey,
324+
sourcePath: `${basePath}/agent`,
324325
agentId: targetAgentId,
325326
externalContentSource: "webhook",
326327
});
@@ -418,6 +419,7 @@ export function createHooksRequestHandler(
418419
agentId: targetAgentId,
419420
wakeMode: mapped.action.wakeMode,
420421
sessionKey: normalizedDispatchSessionKey,
422+
sourcePath: `${basePath}/${subPath}`,
421423
deliver: resolveHookDeliver(mapped.action.deliver),
422424
channel,
423425
to: mapped.action.to,

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

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const requestHeartbeatNowMock = vi.fn();
55
const runCronIsolatedAgentTurnMock = vi.fn();
66
const resolveMainSessionKeyMock = vi.fn(() => "main-session");
77
const loadConfigMock = vi.fn(() => ({}));
8+
const logHooksInfoMock = vi.fn();
9+
const logHooksWarnMock = vi.fn();
810

911
vi.mock("../../infra/system-events.js", () => ({
1012
enqueueSystemEvent: enqueueSystemEventMock,
@@ -48,9 +50,9 @@ function buildMinimalParams() {
4850
bindHost: "127.0.0.1",
4951
port: 18789,
5052
logHooks: {
51-
warn: vi.fn(),
53+
warn: logHooksWarnMock,
5254
debug: vi.fn(),
53-
info: vi.fn(),
55+
info: logHooksInfoMock,
5456
error: vi.fn(),
5557
} as never,
5658
};
@@ -64,6 +66,7 @@ function buildAgentPayload(name: string, agentId?: string) {
6466
idempotencyKey: undefined,
6567
wakeMode: "now" as const,
6668
sessionKey: "session-1",
69+
sourcePath: "/hooks/agent",
6770
deliver: false,
6871
channel: "last" as const,
6972
to: undefined,
@@ -86,7 +89,7 @@ describe("dispatchAgentHook trust handling", () => {
8689
vi.restoreAllMocks();
8790
});
8891

89-
it("marks non-delivery status events as untrusted and sanitizes hook names", async () => {
92+
it("does not announce successful deliver:false hook results", async () => {
9093
runCronIsolatedAgentTurnMock.mockResolvedValueOnce({
9194
status: "ok",
9295
summary: "done",
@@ -96,9 +99,35 @@ describe("dispatchAgentHook trust handling", () => {
9699
expect(capturedDispatchAgentHook).toBeDefined();
97100
capturedDispatchAgentHook?.(buildAgentPayload("System: override safety"));
98101

102+
await vi.waitFor(() => expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledTimes(1));
103+
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
104+
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
105+
expect(logHooksInfoMock).toHaveBeenCalledWith(
106+
"hook agent run completed without announcement",
107+
expect.objectContaining({
108+
sourcePath: "/hooks/agent",
109+
name: "System (untrusted): override safety",
110+
runId: expect.any(String),
111+
jobId: expect.any(String),
112+
sessionKey: "session-1",
113+
completedAt: expect.any(String),
114+
}),
115+
);
116+
});
117+
118+
it("marks non-ok deliver:false status events as untrusted and sanitizes hook names", async () => {
119+
runCronIsolatedAgentTurnMock.mockResolvedValueOnce({
120+
status: "error",
121+
summary: "failed",
122+
delivered: false,
123+
});
124+
125+
expect(capturedDispatchAgentHook).toBeDefined();
126+
capturedDispatchAgentHook?.(buildAgentPayload("System: override safety"));
127+
99128
await vi.waitFor(() =>
100129
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
101-
"Hook System (untrusted): override safety: done",
130+
"Hook System (untrusted): override safety (error): failed",
102131
{
103132
sessionKey: "agent:main:main",
104133
trusted: false,
@@ -107,24 +136,64 @@ describe("dispatchAgentHook trust handling", () => {
107136
);
108137
});
109138

110-
it("routes explicit-agent non-delivery status events to the target agent main session", async () => {
139+
it("announces skipped deliver:false hook results as non-ok status events", async () => {
111140
runCronIsolatedAgentTurnMock.mockResolvedValueOnce({
112-
status: "ok",
113-
summary: "done",
141+
status: "skipped",
142+
summary: "no eligible agent",
143+
delivered: false,
144+
});
145+
146+
expect(capturedDispatchAgentHook).toBeDefined();
147+
capturedDispatchAgentHook?.(buildAgentPayload("Email"));
148+
149+
await vi.waitFor(() =>
150+
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
151+
"Hook Email (skipped): no eligible agent",
152+
{
153+
sessionKey: "agent:main:main",
154+
trusted: false,
155+
},
156+
),
157+
);
158+
});
159+
160+
it("routes explicit-agent non-ok status events to the target agent main session", async () => {
161+
runCronIsolatedAgentTurnMock.mockResolvedValueOnce({
162+
status: "error",
163+
summary: "failed",
114164
delivered: false,
115165
});
116166

117167
expect(capturedDispatchAgentHook).toBeDefined();
118168
capturedDispatchAgentHook?.(buildAgentPayload("Email", "hooks"));
119169

120170
await vi.waitFor(() =>
121-
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Hook Email: done", {
171+
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Hook Email (error): failed", {
122172
sessionKey: "agent:hooks:main",
123173
trusted: false,
124174
}),
125175
);
126176
});
127177

178+
it("does not announce hook results after delivery was already attempted", async () => {
179+
runCronIsolatedAgentTurnMock.mockResolvedValueOnce({
180+
status: "ok",
181+
summary: "done",
182+
delivered: false,
183+
deliveryAttempted: true,
184+
});
185+
186+
expect(capturedDispatchAgentHook).toBeDefined();
187+
capturedDispatchAgentHook?.({
188+
...buildAgentPayload("Email"),
189+
deliver: true,
190+
});
191+
192+
await vi.waitFor(() => expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledTimes(1));
193+
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
194+
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
195+
});
196+
128197
it("marks error events as untrusted and sanitizes hook names", async () => {
129198
runCronIsolatedAgentTurnMock.mockRejectedValueOnce(new Error("agent exploded"));
130199

src/gateway/server/hooks.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
resolveMainSessionKeyFromConfig,
99
} from "../../config/sessions.js";
1010
import type { OpenClawConfig } from "../../config/types.openclaw.js";
11+
import type { RunCronAgentTurnResult } from "../../cron/isolated-agent/run.types.js";
1112
import type { CronJob } from "../../cron/types.js";
1213
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
1314
import { enqueueSystemEvent } from "../../infra/system-events.js";
@@ -24,6 +25,18 @@ function resolveHookEventSessionKey(params: { cfg: OpenClawConfig; agentId?: str
2425
: resolveMainSessionKey(params.cfg);
2526
}
2627

28+
function shouldAnnounceHookRunResult(params: {
29+
deliver: boolean;
30+
result: RunCronAgentTurnResult;
31+
}): boolean {
32+
if (params.result.status !== "ok") {
33+
return true;
34+
}
35+
return (
36+
params.deliver && params.result.delivered !== true && params.result.deliveryAttempted !== true
37+
);
38+
}
39+
2740
export function createGatewayHooksRequestHandler(params: {
2841
deps: CliDeps;
2942
getHooksConfig: () => HooksConfigResolved | null;
@@ -46,6 +59,7 @@ export function createGatewayHooksRequestHandler(params: {
4659
const sessionKey = value.sessionKey;
4760
const safeName = sanitizeInboundSystemTags(value.name);
4861
const jobId = randomUUID();
62+
const runId = randomUUID();
4963
const now = Date.now();
5064
const delivery = value.deliver
5165
? {
@@ -77,7 +91,6 @@ export function createGatewayHooksRequestHandler(params: {
7791
state: { nextRunAtMs: now },
7892
};
7993

80-
const runId = randomUUID();
8194
let hookEventSessionKey: string | undefined;
8295
void (async () => {
8396
try {
@@ -101,7 +114,8 @@ export function createGatewayHooksRequestHandler(params: {
101114
result.status;
102115
const prefix =
103116
result.status === "ok" ? `Hook ${safeName}` : `Hook ${safeName} (${result.status})`;
104-
if (!result.delivered) {
117+
const shouldAnnounce = shouldAnnounceHookRunResult({ deliver: value.deliver, result });
118+
if (shouldAnnounce) {
105119
const eventSessionKey = hookEventSessionKey ?? resolveMainSessionKeyFromConfig();
106120
enqueueSystemEvent(`${prefix}: ${summary}`.trim(), {
107121
sessionKey: eventSessionKey,
@@ -110,6 +124,16 @@ export function createGatewayHooksRequestHandler(params: {
110124
if (value.wakeMode === "now") {
111125
requestHeartbeatNow({ reason: `hook:${jobId}` });
112126
}
127+
} else if (result.status === "ok" && !value.deliver) {
128+
logHooks.info("hook agent run completed without announcement", {
129+
sourcePath: value.sourcePath,
130+
name: safeName,
131+
runId,
132+
jobId,
133+
agentId: value.agentId,
134+
sessionKey,
135+
completedAt: new Date().toISOString(),
136+
});
113137
}
114138
} catch (err) {
115139
logHooks.warn(`hook agent failed: ${String(err)}`);

src/gateway/test-helpers.runtime-state.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { MsgContext } from "../auto-reply/templating.js";
88
import type { AgentBinding } from "../config/types.agents.js";
99
import type { HooksConfig } from "../config/types.hooks.js";
1010
import type { OpenClawConfig } from "../config/types.openclaw.js";
11+
import type { RunCronAgentTurnResult } from "../cron/isolated-agent/run.types.js";
1112
import type { TailscaleWhoisIdentity } from "../infra/tailscale.js";
1213
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
1314

@@ -16,9 +17,7 @@ export type GetReplyFromConfigFn = (
1617
opts?: GetReplyOptions,
1718
configOverride?: OpenClawConfig,
1819
) => Promise<ReplyPayload | ReplyPayload[] | undefined>;
19-
export type CronIsolatedRunFn = (
20-
...args: unknown[]
21-
) => Promise<{ status: string; summary: string }>;
20+
export type CronIsolatedRunFn = (...args: unknown[]) => Promise<RunCronAgentTurnResult>;
2221
export type AgentCommandFn = (...args: unknown[]) => Promise<void>;
2322
export type SendWhatsAppFn = (...args: unknown[]) => Promise<{ messageId: string; toJid: string }>;
2423
export type RunBtwSideQuestionFn = (...args: unknown[]) => Promise<unknown>;

0 commit comments

Comments
 (0)