fix: avoid WhatsApp silent turns with final-only delivery (#24962) (thanks @SidQin-cyber)

This commit is contained in:
Peter Steinberger
2026-02-24 03:46:38 +00:00
parent 3d22af692c
commit b5881d9ef4
3 changed files with 78 additions and 5 deletions

View File

@@ -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.

View File

@@ -9,6 +9,9 @@ let capturedDispatchParams: unknown;
let sessionDir: string | undefined;
let sessionStorePath: string;
let backgroundTasks: Set<Promise<unknown>>;
const { deliverWebReplyMock } = vi.hoisted(() => ({
deliverWebReplyMock: vi.fn(async () => {}),
}));
const defaultReplyLogger = {
info: () => {},
@@ -24,6 +27,7 @@ function makeProcessMessageArgs(params: {
cfg?: unknown;
groupHistories?: Map<string, Array<{ sender: string; body: string }>>;
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<typeof import("../../../config/config.js").loadConfig>,
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<void>)
| 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<typeof import("../../../config/config.js").loadConfig>,
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);
});
});

View File

@@ -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,
},
});