From b5881d9ef44432cc97bbcf94e082616432f49c6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:46:38 +0000 Subject: [PATCH] fix: avoid WhatsApp silent turns with final-only delivery (#24962) (thanks @SidQin-cyber) --- CHANGELOG.md | 1 + .../process-message.inbound-contract.test.ts | 75 ++++++++++++++++++- src/web/auto-reply/monitor/process-message.ts | 7 +- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99db97588..d7800cdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. +- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 0404ec431..0acd4056f 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -9,6 +9,9 @@ let capturedDispatchParams: unknown; let sessionDir: string | undefined; let sessionStorePath: string; let backgroundTasks: Set>; +const { deliverWebReplyMock } = vi.hoisted(() => ({ + deliverWebReplyMock: vi.fn(async () => {}), +})); const defaultReplyLogger = { info: () => {}, @@ -24,6 +27,7 @@ function makeProcessMessageArgs(params: { cfg?: unknown; groupHistories?: Map>; groupHistory?: Array<{ sender: string; body: string }>; + rememberSentText?: (text: string | undefined, opts: unknown) => void; }) { return { // oxlint-disable-next-line typescript/no-explicit-any @@ -47,7 +51,8 @@ function makeProcessMessageArgs(params: { // oxlint-disable-next-line typescript/no-explicit-any replyLogger: defaultReplyLogger as any, backgroundTasks, - rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + rememberSentText: + params.rememberSentText ?? ((_text: string | undefined, _opts: unknown) => {}), echoHas: () => false, echoForget: () => {}, buildCombinedEchoKey: () => "echo", @@ -75,6 +80,10 @@ vi.mock("./last-route.js", () => ({ updateLastRouteInBackground: vi.fn(), })); +vi.mock("../deliver-reply.js", () => ({ + deliverWebReply: deliverWebReplyMock, +})); + import { processMessage } from "./process-message.js"; describe("web processMessage inbound contract", () => { @@ -82,6 +91,7 @@ describe("web processMessage inbound contract", () => { capturedCtx = undefined; capturedDispatchParams = undefined; backgroundTasks = new Set(); + deliverWebReplyMock.mockClear(); sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-process-message-")); sessionStorePath = path.join(sessionDir, "sessions.json"); }); @@ -229,4 +239,67 @@ describe("web processMessage inbound contract", () => { expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0); }); + + it("suppresses non-final WhatsApp payload delivery", async () => { + const rememberSentText = vi.fn(); + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + rememberSentText, + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const deliver = (capturedDispatchParams as any)?.dispatcherOptions?.deliver as + | ((payload: { text?: string }, info: { kind: "tool" | "block" | "final" }) => Promise) + | undefined; + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "tool payload" }, { kind: "tool" }); + await deliver?.({ text: "block payload" }, { kind: "block" }); + expect(deliverWebReplyMock).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + + await deliver?.({ text: "final payload" }, { kind: "final" }); + expect(deliverWebReplyMock).toHaveBeenCalledTimes(1); + expect(rememberSentText).toHaveBeenCalledTimes(1); + }); + + it("forces disableBlockStreaming for WhatsApp dispatch", async () => { + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const replyOptions = (capturedDispatchParams as any)?.replyOptions; + expect(replyOptions?.disableBlockStreaming).toBe(true); + }); }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 1c48d4141..15607a452 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -416,10 +416,9 @@ export async function processMessage(params: { onReplyStart: params.msg.sendComposing, }, replyOptions: { - disableBlockStreaming: - typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean" - ? !params.cfg.channels.whatsapp.blockStreaming - : undefined, + // WhatsApp delivery intentionally suppresses non-final payloads. + // Keep block streaming disabled so final replies are still produced. + disableBlockStreaming: true, onModelSelected, }, });