From 46843211c821c98b0a8a4ba44490dc38050470d4 Mon Sep 17 00:00:00 2001 From: nando Date: Thu, 19 Mar 2026 00:13:31 +0000 Subject: [PATCH 1/2] whatsapp: route Baileys socket and version refresh through proxy --- extensions/whatsapp/src/session.test.ts | 91 +++++++++++++++++++++++++ extensions/whatsapp/src/session.ts | 86 ++++++++++++++++++++++- 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index d86de75ffa7..0171a57dc9e 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -5,6 +5,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; +const { httpsRequestMock, httpsProxyAgentSpy, undiciProxyAgentSpy } = vi.hoisted(() => ({ + httpsRequestMock: vi.fn(), + httpsProxyAgentSpy: vi.fn(), + undiciProxyAgentSpy: vi.fn(), +})); + +vi.mock("node:https", () => ({ + request: httpsRequestMock, +})); + +vi.mock("https-proxy-agent", () => ({ + HttpsProxyAgent: vi.fn(function MockHttpsProxyAgent( + this: { proxyUrl: string }, + proxyUrl: string, + ) { + this.proxyUrl = proxyUrl; + httpsProxyAgentSpy(proxyUrl); + }), +})); + +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + class MockProxyAgent { + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + undiciProxyAgentSpy(proxyUrl); + } + } + return { + ...actual, + ProxyAgent: MockProxyAgent, + }; +}); + const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } = await import("./session.js"); const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState); @@ -59,6 +94,10 @@ describe("web session", () => { vi.clearAllMocks(); resetBaileysMocks(); resetLoadConfigMock(); + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; }); afterEach(() => { @@ -85,6 +124,58 @@ describe("web session", () => { expect(saveCreds).toHaveBeenCalled(); }); + it("adds explicit proxy agents when https proxy env is configured", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + + await createWaSocket(false, false); + + const makeWASocket = baileys.makeWASocket as ReturnType; + const passed = makeWASocket.mock.calls[0]?.[0] as + | { agent?: unknown; fetchAgent?: unknown } + | undefined; + + expect(passed?.agent).toBeDefined(); + expect(passed?.fetchAgent).toBeDefined(); + expect(httpsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); + expect(undiciProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); + }); + + it("refreshes the WhatsApp Web version through the proxy when Baileys falls back", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + vi.mocked(baileys.fetchLatestBaileysVersion).mockResolvedValueOnce({ + version: [2, 3000, 1027934701], + isLatest: false, + }); + httpsRequestMock.mockImplementationOnce( + ( + _url: string, + _opts: Record, + cb: (res: EventEmitter & { statusCode?: number }) => void, + ) => { + const res = new EventEmitter() as EventEmitter & { statusCode?: number }; + res.statusCode = 200; + const req = new EventEmitter() as EventEmitter & { + end: () => void; + destroy: (_err?: Error) => void; + }; + req.end = () => { + cb(res); + res.emit("data", 'self.client_revision="1035441841";'); + res.emit("end"); + }; + req.destroy = () => {}; + return req; + }, + ); + + await createWaSocket(false, false); + + const makeWASocket = baileys.makeWASocket as ReturnType; + const passed = makeWASocket.mock.calls[0]?.[0] as { version?: unknown } | undefined; + expect(passed?.version).toEqual([2, 3000, 1035441841]); + expect(httpsRequestMock).toHaveBeenCalled(); + }); + it("waits for connection open", async () => { const ev = new EventEmitter(); const promise = waitForWaConnection({ ev } as unknown as ReturnType< diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 80690b110eb..70a296eb5fa 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; +import { request as httpsRequest } from "node:https"; import { DisconnectReason, fetchLatestBaileysVersion, @@ -7,12 +8,15 @@ import { makeWASocket, useMultiFileAuthState, } from "@whiskeysockets/baileys"; +import { HttpsProxyAgent } from "https-proxy-agent"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { VERSION } from "openclaw/plugin-sdk/cli-runtime"; import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env"; import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import qrcode from "qrcode-terminal"; +import { ProxyAgent } from "undici"; +import { resolveEnvHttpProxyUrl } from "../../../src/infra/net/proxy-env.js"; import { maybeRestoreCredsFromBackup, readCredsJsonRaw, @@ -34,6 +38,8 @@ export { // Per-authDir queues so multi-account creds saves don't block each other. const credsSaveQueues = new Map>(); const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000; +const WHATSAPP_WEB_SW_URL = "https://web.whatsapp.com/sw.js"; + function enqueueSaveCreds( authDir: string, saveCreds: () => Promise | void, @@ -90,6 +96,79 @@ async function safeSaveCreds( } } +function extractWhatsAppWebVersion(source: string): [number, number, number] | null { + const match = source.match(/client_revision[^0-9]*(\d+)/); + if (!match) { + return null; + } + const revision = Number.parseInt(match[1] ?? "", 10); + if (!Number.isFinite(revision) || revision <= 0) { + return null; + } + return [2, 3000, revision]; +} + +async function fetchLatestWhatsAppWebVersionViaProxy( + proxyUrl: string, +): Promise<[number, number, number] | null> { + return await new Promise((resolve) => { + const agent = new HttpsProxyAgent(proxyUrl); + const req = httpsRequest( + WHATSAPP_WEB_SW_URL, + { + agent, + timeout: 10_000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: string | Buffer) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + res.on("end", () => { + const statusCode = typeof res.statusCode === "number" ? res.statusCode : 0; + if (statusCode < 200 || statusCode >= 300) { + resolve(null); + return; + } + resolve(extractWhatsAppWebVersion(Buffer.concat(chunks).toString("utf8"))); + }); + }, + ); + req.on("timeout", () => { + req.destroy(new Error("Timed out fetching WhatsApp Web version")); + }); + req.on("error", () => resolve(null)); + req.end(); + }); +} + +async function resolveWhatsAppWebVersion( + sessionLogger: ReturnType, +): Promise<[number, number, number]> { + const latest = await fetchLatestBaileysVersion(); + if (latest.isLatest) { + return latest.version; + } + + const proxyUrl = resolveEnvHttpProxyUrl("https"); + if (!proxyUrl) { + return latest.version; + } + + const proxyVersion = await fetchLatestWhatsAppWebVersionViaProxy(proxyUrl); + if (!proxyVersion) { + return latest.version; + } + + sessionLogger.info( + { + version: proxyVersion, + }, + "using proxy-refreshed WhatsApp Web version", + ); + return proxyVersion; +} + /** * Create a Baileys socket backed by the multi-file auth store we keep on disk. * Consumers can opt into QR printing for interactive login flows. @@ -111,7 +190,10 @@ export async function createWaSocket( const sessionLogger = getChildLogger({ module: "web-session" }); maybeRestoreCredsFromBackup(authDir); const { state, saveCreds } = await useMultiFileAuthState(authDir); - const { version } = await fetchLatestBaileysVersion(); + const version = await resolveWhatsAppWebVersion(sessionLogger); + const proxyUrl = resolveEnvHttpProxyUrl("https"); + const wsAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; + const fetchAgent = proxyUrl ? new ProxyAgent(proxyUrl) : undefined; const sock = makeWASocket({ auth: { creds: state.creds, @@ -123,6 +205,8 @@ export async function createWaSocket( browser: ["openclaw", "cli", VERSION], syncFullHistory: false, markOnlineOnConnect: false, + ...(wsAgent ? { agent: wsAgent } : {}), + ...(fetchAgent ? { fetchAgent } : {}), }); sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, sessionLogger)); From 2d4a80ccbb9f2c32ff1871f56321cfee489dbf5b Mon Sep 17 00:00:00 2001 From: nando Date: Thu, 19 Mar 2026 00:44:48 +0000 Subject: [PATCH 2/2] whatsapp: respect no_proxy in proxy-backed web sessions --- extensions/whatsapp/src/session.test.ts | 84 ++++++------ extensions/whatsapp/src/session.ts | 166 ++++++++++++++++++------ 2 files changed, 172 insertions(+), 78 deletions(-) diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index 0171a57dc9e..9d82a13e4c9 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -5,14 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; -const { httpsRequestMock, httpsProxyAgentSpy, undiciProxyAgentSpy } = vi.hoisted(() => ({ - httpsRequestMock: vi.fn(), +const { httpsProxyAgentSpy, envHttpProxyAgentSpy, undiciFetchMock } = vi.hoisted(() => ({ httpsProxyAgentSpy: vi.fn(), - undiciProxyAgentSpy: vi.fn(), -})); - -vi.mock("node:https", () => ({ - request: httpsRequestMock, + envHttpProxyAgentSpy: vi.fn(), + undiciFetchMock: vi.fn(), })); vi.mock("https-proxy-agent", () => ({ @@ -27,16 +23,17 @@ vi.mock("https-proxy-agent", () => ({ vi.mock("undici", async (importOriginal) => { const actual = await importOriginal(); - class MockProxyAgent { - proxyUrl: string; - constructor(proxyUrl: string) { - this.proxyUrl = proxyUrl; - undiciProxyAgentSpy(proxyUrl); + class MockEnvHttpProxyAgent { + options?: Record; + constructor(options?: Record) { + this.options = options; + envHttpProxyAgentSpy(options); } } return { ...actual, - ProxyAgent: MockProxyAgent, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, + fetch: undiciFetchMock, }; }); @@ -98,6 +95,8 @@ describe("web session", () => { delete process.env.https_proxy; delete process.env.HTTP_PROXY; delete process.env.HTTPS_PROXY; + delete process.env.no_proxy; + delete process.env.NO_PROXY; }); afterEach(() => { @@ -137,43 +136,54 @@ describe("web session", () => { expect(passed?.agent).toBeDefined(); expect(passed?.fetchAgent).toBeDefined(); expect(httpsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); - expect(undiciProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); + expect(envHttpProxyAgentSpy).toHaveBeenCalled(); }); - it("refreshes the WhatsApp Web version through the proxy when Baileys falls back", async () => { + it("does not force the websocket through the proxy when NO_PROXY excludes web.whatsapp.com", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + process.env.NO_PROXY = "web.whatsapp.com"; + + await createWaSocket(false, false); + + const makeWASocket = baileys.makeWASocket as ReturnType; + const passed = makeWASocket.mock.calls[0]?.[0] as + | { agent?: unknown; fetchAgent?: unknown } + | undefined; + + expect(passed?.agent).toBeUndefined(); + expect(passed?.fetchAgent).toBeDefined(); + expect(httpsProxyAgentSpy).not.toHaveBeenCalled(); + expect(envHttpProxyAgentSpy).toHaveBeenCalled(); + }); + + it("refreshes the WhatsApp Web version with Baileys headers when Baileys falls back", async () => { process.env.HTTPS_PROXY = "http://proxy.test:3128"; vi.mocked(baileys.fetchLatestBaileysVersion).mockResolvedValueOnce({ version: [2, 3000, 1027934701], isLatest: false, }); - httpsRequestMock.mockImplementationOnce( - ( - _url: string, - _opts: Record, - cb: (res: EventEmitter & { statusCode?: number }) => void, - ) => { - const res = new EventEmitter() as EventEmitter & { statusCode?: number }; - res.statusCode = 200; - const req = new EventEmitter() as EventEmitter & { - end: () => void; - destroy: (_err?: Error) => void; - }; - req.end = () => { - cb(res); - res.emit("data", 'self.client_revision="1035441841";'); - res.emit("end"); - }; - req.destroy = () => {}; - return req; - }, - ); + undiciFetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + body: null, + text: vi.fn().mockResolvedValue('self.client_revision="1035441841";'), + } as unknown as Response); await createWaSocket(false, false); const makeWASocket = baileys.makeWASocket as ReturnType; const passed = makeWASocket.mock.calls[0]?.[0] as { version?: unknown } | undefined; expect(passed?.version).toEqual([2, 3000, 1035441841]); - expect(httpsRequestMock).toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://web.whatsapp.com/sw.js", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "sec-fetch-site": "none", + "user-agent": expect.stringContaining("Chrome/131.0.0.0"), + }), + }), + ); }); it("waits for connection open", async () => { diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 70a296eb5fa..1aba1acf7c4 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; -import { request as httpsRequest } from "node:https"; import { DisconnectReason, fetchLatestBaileysVersion, @@ -15,8 +14,9 @@ import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env"; import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import qrcode from "qrcode-terminal"; -import { ProxyAgent } from "undici"; +import { EnvHttpProxyAgent } from "undici"; import { resolveEnvHttpProxyUrl } from "../../../src/infra/net/proxy-env.js"; +import { resolveProxyFetchFromEnv } from "../../../src/infra/net/proxy-fetch.js"; import { maybeRestoreCredsFromBackup, readCredsJsonRaw, @@ -39,6 +39,13 @@ export { const credsSaveQueues = new Map>(); const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000; const WHATSAPP_WEB_SW_URL = "https://web.whatsapp.com/sw.js"; +const WHATSAPP_WEB_SW_MAX_BYTES = 512 * 1024; +const WHATSAPP_WEB_VERSION_HEADERS = { + "sec-fetch-site": "none", + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +} as const; +const WHATSAPP_WEB_SOCKET_HOST = "web.whatsapp.com"; function enqueueSaveCreds( authDir: string, @@ -108,40 +115,118 @@ function extractWhatsAppWebVersion(source: string): [number, number, number] | n return [2, 3000, revision]; } -async function fetchLatestWhatsAppWebVersionViaProxy( - proxyUrl: string, -): Promise<[number, number, number] | null> { - return await new Promise((resolve) => { - const agent = new HttpsProxyAgent(proxyUrl); - const req = httpsRequest( - WHATSAPP_WEB_SW_URL, - { - agent, - timeout: 10_000, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: string | Buffer) => { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - }); - res.on("end", () => { - const statusCode = typeof res.statusCode === "number" ? res.statusCode : 0; - if (statusCode < 200 || statusCode >= 300) { - resolve(null); - return; - } - resolve(extractWhatsAppWebVersion(Buffer.concat(chunks).toString("utf8"))); - }); - }, - ); - req.on("timeout", () => { - req.destroy(new Error("Timed out fetching WhatsApp Web version")); - }); - req.on("error", () => resolve(null)); - req.end(); +function parseNoProxyRules(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.no_proxy ?? env.NO_PROXY; + if (!raw) { + return []; + } + return raw + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); +} + +function stripNoProxyPort(rule: string): string { + if (rule.startsWith("[") && rule.includes("]")) { + const end = rule.indexOf("]"); + return end >= 0 ? rule.slice(0, end + 1) : rule; + } + const colonIndex = rule.lastIndexOf(":"); + if (colonIndex <= 0 || rule.includes(".")) { + return colonIndex > 0 ? rule.slice(0, colonIndex) : rule; + } + return rule; +} + +function shouldBypassEnvProxyForHostname( + hostname: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const normalizedHost = hostname.trim().toLowerCase(); + if (!normalizedHost) { + return false; + } + return parseNoProxyRules(env).some((rawRule) => { + if (rawRule === "*") { + return true; + } + const rule = stripNoProxyPort(rawRule).replace(/^\*\./, ".").toLowerCase(); + if (!rule) { + return false; + } + if (rule.startsWith(".")) { + const bareRule = rule.slice(1); + return normalizedHost === bareRule || normalizedHost.endsWith(rule); + } + return normalizedHost === rule || normalizedHost.endsWith(`.${rule}`); }); } +async function readResponseTextCapped( + response: Response, + maxBytes: number, +): Promise { + const contentLengthHeader = response.headers.get("content-length"); + if (contentLengthHeader) { + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return null; + } + } + if (!response.body) { + const text = await response.text(); + return Buffer.byteLength(text, "utf8") <= maxBytes ? text : null; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + try { + await reader.cancel("WhatsApp sw.js response too large"); + } catch { + // ignore reader cancellation errors + } + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function fetchLatestWhatsAppWebVersion( + fetchImpl: typeof fetch, +): Promise<[number, number, number] | null> { + try { + const response = await fetchImpl(WHATSAPP_WEB_SW_URL, { + method: "GET", + headers: WHATSAPP_WEB_VERSION_HEADERS, + }); + if (!response.ok) { + return null; + } + const source = await readResponseTextCapped(response, WHATSAPP_WEB_SW_MAX_BYTES); + if (!source) { + return null; + } + return extractWhatsAppWebVersion(source); + } catch { + return null; + } +} + async function resolveWhatsAppWebVersion( sessionLogger: ReturnType, ): Promise<[number, number, number]> { @@ -150,12 +235,8 @@ async function resolveWhatsAppWebVersion( return latest.version; } - const proxyUrl = resolveEnvHttpProxyUrl("https"); - if (!proxyUrl) { - return latest.version; - } - - const proxyVersion = await fetchLatestWhatsAppWebVersionViaProxy(proxyUrl); + const fetchImpl = resolveProxyFetchFromEnv() ?? globalThis.fetch; + const proxyVersion = fetchImpl ? await fetchLatestWhatsAppWebVersion(fetchImpl) : null; if (!proxyVersion) { return latest.version; } @@ -192,8 +273,11 @@ export async function createWaSocket( const { state, saveCreds } = await useMultiFileAuthState(authDir); const version = await resolveWhatsAppWebVersion(sessionLogger); const proxyUrl = resolveEnvHttpProxyUrl("https"); - const wsAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; - const fetchAgent = proxyUrl ? new ProxyAgent(proxyUrl) : undefined; + const wsAgent = + proxyUrl && !shouldBypassEnvProxyForHostname(WHATSAPP_WEB_SOCKET_HOST) + ? new HttpsProxyAgent(proxyUrl) + : undefined; + const fetchAgent = proxyUrl ? new EnvHttpProxyAgent() : undefined; const sock = makeWASocket({ auth: { creds: state.creds,