diff --git a/src/browser/cdp-proxy-bypass.test.ts b/src/browser/cdp-proxy-bypass.test.ts new file mode 100644 index 00000000000..bcb60e662cb --- /dev/null +++ b/src/browser/cdp-proxy-bypass.test.ts @@ -0,0 +1,159 @@ +import http from "node:http"; +import https from "node:https"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getDirectAgentForCdp, hasProxyEnv, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; + +describe("cdp-proxy-bypass", () => { + describe("getDirectAgentForCdp", () => { + it("returns http.Agent for http://localhost URLs", () => { + const agent = getDirectAgentForCdp("http://localhost:9222"); + expect(agent).toBeInstanceOf(http.Agent); + }); + + it("returns http.Agent for http://127.0.0.1 URLs", () => { + const agent = getDirectAgentForCdp("http://127.0.0.1:9222/json/version"); + expect(agent).toBeInstanceOf(http.Agent); + }); + + it("returns https.Agent for wss://localhost URLs", () => { + const agent = getDirectAgentForCdp("wss://localhost:9222"); + expect(agent).toBeInstanceOf(https.Agent); + }); + + it("returns http.Agent for ws://[::1] URLs", () => { + const agent = getDirectAgentForCdp("ws://[::1]:9222"); + expect(agent).toBeInstanceOf(http.Agent); + }); + + it("returns undefined for non-loopback URLs", () => { + expect(getDirectAgentForCdp("http://remote-host:9222")).toBeUndefined(); + expect(getDirectAgentForCdp("https://example.com:9222")).toBeUndefined(); + }); + + it("returns undefined for invalid URLs", () => { + expect(getDirectAgentForCdp("not-a-url")).toBeUndefined(); + }); + }); + + describe("hasProxyEnv", () => { + const proxyVars = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ]; + const saved: Record = {}; + + beforeEach(() => { + for (const v of proxyVars) { + saved[v] = process.env[v]; + } + for (const v of proxyVars) { + delete process.env[v]; + } + }); + + afterEach(() => { + for (const v of proxyVars) { + if (saved[v] !== undefined) { + process.env[v] = saved[v]; + } else { + delete process.env[v]; + } + } + }); + + it("returns false when no proxy vars set", () => { + expect(hasProxyEnv()).toBe(false); + }); + + it("returns true when HTTP_PROXY is set", () => { + process.env.HTTP_PROXY = "http://proxy:8080"; + expect(hasProxyEnv()).toBe(true); + }); + + it("returns true when ALL_PROXY is set", () => { + process.env.ALL_PROXY = "socks5://proxy:1080"; + expect(hasProxyEnv()).toBe(true); + }); + }); + + describe("withNoProxyForLocalhost", () => { + const saved: Record = {}; + const vars = ["HTTP_PROXY", "NO_PROXY", "no_proxy"]; + + beforeEach(() => { + for (const v of vars) { + saved[v] = process.env[v]; + } + }); + + afterEach(() => { + for (const v of vars) { + if (saved[v] !== undefined) { + process.env[v] = saved[v]; + } else { + delete process.env[v]; + } + } + }); + + it("sets NO_PROXY when proxy is configured", async () => { + process.env.HTTP_PROXY = "http://proxy:8080"; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + + let capturedNoProxy: string | undefined; + await withNoProxyForLocalhost(async () => { + capturedNoProxy = process.env.NO_PROXY; + }); + + expect(capturedNoProxy).toContain("localhost"); + expect(capturedNoProxy).toContain("127.0.0.1"); + expect(capturedNoProxy).toContain("[::1]"); + // Restored after + expect(process.env.NO_PROXY).toBeUndefined(); + }); + + it("extends existing NO_PROXY", async () => { + process.env.HTTP_PROXY = "http://proxy:8080"; + process.env.NO_PROXY = "internal.corp"; + + let capturedNoProxy: string | undefined; + await withNoProxyForLocalhost(async () => { + capturedNoProxy = process.env.NO_PROXY; + }); + + expect(capturedNoProxy).toContain("internal.corp"); + expect(capturedNoProxy).toContain("localhost"); + // Restored + expect(process.env.NO_PROXY).toBe("internal.corp"); + }); + + it("skips when no proxy env is set", async () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.ALL_PROXY; + delete process.env.NO_PROXY; + + await withNoProxyForLocalhost(async () => { + expect(process.env.NO_PROXY).toBeUndefined(); + }); + }); + + it("restores env even on error", async () => { + process.env.HTTP_PROXY = "http://proxy:8080"; + delete process.env.NO_PROXY; + + await expect( + withNoProxyForLocalhost(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(process.env.NO_PROXY).toBeUndefined(); + }); + }); +}); diff --git a/src/browser/cdp-proxy-bypass.ts b/src/browser/cdp-proxy-bypass.ts new file mode 100644 index 00000000000..61e8eda2e2e --- /dev/null +++ b/src/browser/cdp-proxy-bypass.ts @@ -0,0 +1,92 @@ +/** + * Proxy bypass for CDP (Chrome DevTools Protocol) localhost connections. + * + * When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set, + * CDP connections to localhost/127.0.0.1 can be incorrectly routed through + * the proxy, causing browser control to fail. + * + * @see https://github.com/nicepkg/openclaw/issues/31219 + */ +import http from "node:http"; +import https from "node:https"; +import { isLoopbackHost } from "../gateway/net.js"; + +/** HTTP agent that never uses a proxy — for localhost CDP connections. */ +const directHttpAgent = new http.Agent(); +const directHttpsAgent = new https.Agent(); + +/** + * Returns a plain (non-proxy) agent for WebSocket or HTTP connections + * when the target is a loopback address. Returns `undefined` otherwise + * so callers fall through to their default behaviour. + */ +export function getDirectAgentForCdp(url: string): http.Agent | https.Agent | undefined { + try { + const parsed = new URL(url); + if (isLoopbackHost(parsed.hostname)) { + return parsed.protocol === "https:" || parsed.protocol === "wss:" + ? directHttpsAgent + : directHttpAgent; + } + } catch { + // not a valid URL — let caller handle it + } + return undefined; +} + +/** + * Returns `true` when any proxy-related env var is set that could + * interfere with loopback connections. + */ +export function hasProxyEnv(): boolean { + const env = process.env; + return Boolean( + env.HTTP_PROXY || + env.http_proxy || + env.HTTPS_PROXY || + env.https_proxy || + env.ALL_PROXY || + env.all_proxy, + ); +} + +/** + * Run an async function with NO_PROXY temporarily extended to include + * localhost and 127.0.0.1. Restores the original value afterwards. + * + * Used for third-party code (e.g. Playwright) that reads env vars + * internally and doesn't accept an explicit agent. + */ +export async function withNoProxyForLocalhost(fn: () => Promise): Promise { + if (!hasProxyEnv()) { + return fn(); + } + + const origNoProxy = process.env.NO_PROXY; + const origNoProxyLower = process.env.no_proxy; + const loopbackEntries = "localhost,127.0.0.1,[::1]"; + + const current = origNoProxy || origNoProxyLower || ""; + const alreadyCoversLocalhost = current.includes("localhost") && current.includes("127.0.0.1"); + + if (!alreadyCoversLocalhost) { + const extended = current ? `${current},${loopbackEntries}` : loopbackEntries; + process.env.NO_PROXY = extended; + process.env.no_proxy = extended; + } + + try { + return await fn(); + } finally { + if (origNoProxy !== undefined) { + process.env.NO_PROXY = origNoProxy; + } else { + delete process.env.NO_PROXY; + } + if (origNoProxyLower !== undefined) { + process.env.no_proxy = origNoProxyLower; + } else { + delete process.env.no_proxy; + } + } +} diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index eae8ef989ed..90fa23286ba 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -1,6 +1,7 @@ import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; +import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; @@ -122,7 +123,10 @@ async function fetchChecked(url: string, timeoutMs = 1500, init?: RequestInit): const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); try { const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); - const res = await fetch(url, { ...init, headers, signal: ctrl.signal }); + // Bypass proxy for loopback CDP connections (#31219) + const res = await withNoProxyForLocalhost(() => + fetch(url, { ...init, headers, signal: ctrl.signal }), + ); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } @@ -146,9 +150,12 @@ export async function withCdpSocket( typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) : 5000; + // Bypass proxy for loopback CDP connections (#31219) + const agent = getDirectAgentForCdp(wsUrl); const ws = new WebSocket(wsUrl, { handshakeTimeout: handshakeTimeoutMs, ...(Object.keys(headers).length ? { headers } : {}), + ...(agent ? { agent } : {}), }); const { send, closeWithError } = createCdpSender(ws); diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index d6dc9990ffd..672ddb8f64c 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -6,6 +6,7 @@ import WebSocket from "ws"; import { ensurePortAvailable } from "../infra/ports.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { CONFIG_DIR } from "../utils.js"; +import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; import { appendCdpPath } from "./cdp.helpers.js"; import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js"; import { @@ -83,10 +84,13 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise + fetch(versionUrl, { + signal: ctrl.signal, + headers: getHeadersWithAuth(versionUrl), + }), + ); if (!res.ok) { return null; } @@ -117,9 +121,12 @@ export async function getChromeWebSocketUrl( async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise { return await new Promise((resolve) => { const headers = getHeadersWithAuth(wsUrl); + // Bypass proxy for loopback CDP connections (#31219) + const wsAgent = getDirectAgentForCdp(wsUrl); const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs, ...(Object.keys(headers).length ? { headers } : {}), + ...(wsAgent ? { agent: wsAgent } : {}), }); const timer = setTimeout( () => { diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index f07bcfeae98..a0611105d7e 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -9,6 +9,7 @@ import type { import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { withNoProxyForLocalhost } from "./cdp-proxy-bypass.js"; import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { getChromeWebSocketUrl } from "./chrome.js"; @@ -336,7 +337,10 @@ async function connectBrowser(cdpUrl: string): Promise { const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null); const endpoint = wsUrl ?? normalized; const headers = getHeadersWithAuth(endpoint); - const browser = await chromium.connectOverCDP(endpoint, { timeout, headers }); + // Bypass proxy for loopback CDP connections (#31219) + const browser = await withNoProxyForLocalhost(() => + chromium.connectOverCDP(endpoint, { timeout, headers }), + ); const onDisconnected = () => { if (cached?.browser === browser) { cached = null;