Skip to content

Commit c96234b

Browse files
widingmarcus-cybersteipete
authored andcommitted
fix: bypass proxy for CDP localhost connections (#31219)
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 (e.g. via global-agent or undici proxy dispatcher), causing browser control to fail. Fix: - New cdp-proxy-bypass module with utilities for direct localhost connections - WebSocket (ws) CDP connections: pass explicit http.Agent to bypass any global proxy agent patching - fetch-based CDP probes: wrap in withNoProxyForLocalhost() to temporarily set NO_PROXY for the duration of the call - Playwright connectOverCDP: wrap in withNoProxyForLocalhost() since Playwright reads env vars internally - 13 new tests covering getDirectAgentForCdp, hasProxyEnv, and withNoProxyForLocalhost (env save/restore, error recovery)
1 parent 1184d39 commit c96234b

5 files changed

Lines changed: 275 additions & 6 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import http from "node:http";
2+
import https from "node:https";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { getDirectAgentForCdp, hasProxyEnv, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
5+
6+
describe("cdp-proxy-bypass", () => {
7+
describe("getDirectAgentForCdp", () => {
8+
it("returns http.Agent for http://localhost URLs", () => {
9+
const agent = getDirectAgentForCdp("http://localhost:9222");
10+
expect(agent).toBeInstanceOf(http.Agent);
11+
});
12+
13+
it("returns http.Agent for http://127.0.0.1 URLs", () => {
14+
const agent = getDirectAgentForCdp("http://127.0.0.1:9222/json/version");
15+
expect(agent).toBeInstanceOf(http.Agent);
16+
});
17+
18+
it("returns https.Agent for wss://localhost URLs", () => {
19+
const agent = getDirectAgentForCdp("wss://localhost:9222");
20+
expect(agent).toBeInstanceOf(https.Agent);
21+
});
22+
23+
it("returns http.Agent for ws://[::1] URLs", () => {
24+
const agent = getDirectAgentForCdp("ws://[::1]:9222");
25+
expect(agent).toBeInstanceOf(http.Agent);
26+
});
27+
28+
it("returns undefined for non-loopback URLs", () => {
29+
expect(getDirectAgentForCdp("http://remote-host:9222")).toBeUndefined();
30+
expect(getDirectAgentForCdp("https://example.com:9222")).toBeUndefined();
31+
});
32+
33+
it("returns undefined for invalid URLs", () => {
34+
expect(getDirectAgentForCdp("not-a-url")).toBeUndefined();
35+
});
36+
});
37+
38+
describe("hasProxyEnv", () => {
39+
const proxyVars = [
40+
"HTTP_PROXY",
41+
"http_proxy",
42+
"HTTPS_PROXY",
43+
"https_proxy",
44+
"ALL_PROXY",
45+
"all_proxy",
46+
];
47+
const saved: Record<string, string | undefined> = {};
48+
49+
beforeEach(() => {
50+
for (const v of proxyVars) {
51+
saved[v] = process.env[v];
52+
}
53+
for (const v of proxyVars) {
54+
delete process.env[v];
55+
}
56+
});
57+
58+
afterEach(() => {
59+
for (const v of proxyVars) {
60+
if (saved[v] !== undefined) {
61+
process.env[v] = saved[v];
62+
} else {
63+
delete process.env[v];
64+
}
65+
}
66+
});
67+
68+
it("returns false when no proxy vars set", () => {
69+
expect(hasProxyEnv()).toBe(false);
70+
});
71+
72+
it("returns true when HTTP_PROXY is set", () => {
73+
process.env.HTTP_PROXY = "http://proxy:8080";
74+
expect(hasProxyEnv()).toBe(true);
75+
});
76+
77+
it("returns true when ALL_PROXY is set", () => {
78+
process.env.ALL_PROXY = "socks5://proxy:1080";
79+
expect(hasProxyEnv()).toBe(true);
80+
});
81+
});
82+
83+
describe("withNoProxyForLocalhost", () => {
84+
const saved: Record<string, string | undefined> = {};
85+
const vars = ["HTTP_PROXY", "NO_PROXY", "no_proxy"];
86+
87+
beforeEach(() => {
88+
for (const v of vars) {
89+
saved[v] = process.env[v];
90+
}
91+
});
92+
93+
afterEach(() => {
94+
for (const v of vars) {
95+
if (saved[v] !== undefined) {
96+
process.env[v] = saved[v];
97+
} else {
98+
delete process.env[v];
99+
}
100+
}
101+
});
102+
103+
it("sets NO_PROXY when proxy is configured", async () => {
104+
process.env.HTTP_PROXY = "http://proxy:8080";
105+
delete process.env.NO_PROXY;
106+
delete process.env.no_proxy;
107+
108+
let capturedNoProxy: string | undefined;
109+
await withNoProxyForLocalhost(async () => {
110+
capturedNoProxy = process.env.NO_PROXY;
111+
});
112+
113+
expect(capturedNoProxy).toContain("localhost");
114+
expect(capturedNoProxy).toContain("127.0.0.1");
115+
expect(capturedNoProxy).toContain("[::1]");
116+
// Restored after
117+
expect(process.env.NO_PROXY).toBeUndefined();
118+
});
119+
120+
it("extends existing NO_PROXY", async () => {
121+
process.env.HTTP_PROXY = "http://proxy:8080";
122+
process.env.NO_PROXY = "internal.corp";
123+
124+
let capturedNoProxy: string | undefined;
125+
await withNoProxyForLocalhost(async () => {
126+
capturedNoProxy = process.env.NO_PROXY;
127+
});
128+
129+
expect(capturedNoProxy).toContain("internal.corp");
130+
expect(capturedNoProxy).toContain("localhost");
131+
// Restored
132+
expect(process.env.NO_PROXY).toBe("internal.corp");
133+
});
134+
135+
it("skips when no proxy env is set", async () => {
136+
delete process.env.HTTP_PROXY;
137+
delete process.env.HTTPS_PROXY;
138+
delete process.env.ALL_PROXY;
139+
delete process.env.NO_PROXY;
140+
141+
await withNoProxyForLocalhost(async () => {
142+
expect(process.env.NO_PROXY).toBeUndefined();
143+
});
144+
});
145+
146+
it("restores env even on error", async () => {
147+
process.env.HTTP_PROXY = "http://proxy:8080";
148+
delete process.env.NO_PROXY;
149+
150+
await expect(
151+
withNoProxyForLocalhost(async () => {
152+
throw new Error("boom");
153+
}),
154+
).rejects.toThrow("boom");
155+
156+
expect(process.env.NO_PROXY).toBeUndefined();
157+
});
158+
});
159+
});

src/browser/cdp-proxy-bypass.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Proxy bypass for CDP (Chrome DevTools Protocol) localhost connections.
3+
*
4+
* When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set,
5+
* CDP connections to localhost/127.0.0.1 can be incorrectly routed through
6+
* the proxy, causing browser control to fail.
7+
*
8+
* @see https://github.com/nicepkg/openclaw/issues/31219
9+
*/
10+
import http from "node:http";
11+
import https from "node:https";
12+
import { isLoopbackHost } from "../gateway/net.js";
13+
14+
/** HTTP agent that never uses a proxy — for localhost CDP connections. */
15+
const directHttpAgent = new http.Agent();
16+
const directHttpsAgent = new https.Agent();
17+
18+
/**
19+
* Returns a plain (non-proxy) agent for WebSocket or HTTP connections
20+
* when the target is a loopback address. Returns `undefined` otherwise
21+
* so callers fall through to their default behaviour.
22+
*/
23+
export function getDirectAgentForCdp(url: string): http.Agent | https.Agent | undefined {
24+
try {
25+
const parsed = new URL(url);
26+
if (isLoopbackHost(parsed.hostname)) {
27+
return parsed.protocol === "https:" || parsed.protocol === "wss:"
28+
? directHttpsAgent
29+
: directHttpAgent;
30+
}
31+
} catch {
32+
// not a valid URL — let caller handle it
33+
}
34+
return undefined;
35+
}
36+
37+
/**
38+
* Returns `true` when any proxy-related env var is set that could
39+
* interfere with loopback connections.
40+
*/
41+
export function hasProxyEnv(): boolean {
42+
const env = process.env;
43+
return Boolean(
44+
env.HTTP_PROXY ||
45+
env.http_proxy ||
46+
env.HTTPS_PROXY ||
47+
env.https_proxy ||
48+
env.ALL_PROXY ||
49+
env.all_proxy,
50+
);
51+
}
52+
53+
/**
54+
* Run an async function with NO_PROXY temporarily extended to include
55+
* localhost and 127.0.0.1. Restores the original value afterwards.
56+
*
57+
* Used for third-party code (e.g. Playwright) that reads env vars
58+
* internally and doesn't accept an explicit agent.
59+
*/
60+
export async function withNoProxyForLocalhost<T>(fn: () => Promise<T>): Promise<T> {
61+
if (!hasProxyEnv()) {
62+
return fn();
63+
}
64+
65+
const origNoProxy = process.env.NO_PROXY;
66+
const origNoProxyLower = process.env.no_proxy;
67+
const loopbackEntries = "localhost,127.0.0.1,[::1]";
68+
69+
const current = origNoProxy || origNoProxyLower || "";
70+
const alreadyCoversLocalhost = current.includes("localhost") && current.includes("127.0.0.1");
71+
72+
if (!alreadyCoversLocalhost) {
73+
const extended = current ? `${current},${loopbackEntries}` : loopbackEntries;
74+
process.env.NO_PROXY = extended;
75+
process.env.no_proxy = extended;
76+
}
77+
78+
try {
79+
return await fn();
80+
} finally {
81+
if (origNoProxy !== undefined) {
82+
process.env.NO_PROXY = origNoProxy;
83+
} else {
84+
delete process.env.NO_PROXY;
85+
}
86+
if (origNoProxyLower !== undefined) {
87+
process.env.no_proxy = origNoProxyLower;
88+
} else {
89+
delete process.env.no_proxy;
90+
}
91+
}
92+
}

src/browser/cdp.helpers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import WebSocket from "ws";
22
import { isLoopbackHost } from "../gateway/net.js";
33
import { rawDataToString } from "../infra/ws.js";
4+
import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
45
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
56

67
export { isLoopbackHost };
@@ -122,7 +123,10 @@ async function fetchChecked(url: string, timeoutMs = 1500, init?: RequestInit):
122123
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
123124
try {
124125
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
125-
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
126+
// Bypass proxy for loopback CDP connections (#31219)
127+
const res = await withNoProxyForLocalhost(() =>
128+
fetch(url, { ...init, headers, signal: ctrl.signal }),
129+
);
126130
if (!res.ok) {
127131
throw new Error(`HTTP ${res.status}`);
128132
}
@@ -146,9 +150,12 @@ export async function withCdpSocket<T>(
146150
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
147151
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
148152
: 5000;
153+
// Bypass proxy for loopback CDP connections (#31219)
154+
const agent = getDirectAgentForCdp(wsUrl);
149155
const ws = new WebSocket(wsUrl, {
150156
handshakeTimeout: handshakeTimeoutMs,
151157
...(Object.keys(headers).length ? { headers } : {}),
158+
...(agent ? { agent } : {}),
152159
});
153160
const { send, closeWithError } = createCdpSender(ws);
154161

src/browser/chrome.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import WebSocket from "ws";
66
import { ensurePortAvailable } from "../infra/ports.js";
77
import { createSubsystemLogger } from "../logging/subsystem.js";
88
import { CONFIG_DIR } from "../utils.js";
9+
import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
910
import { appendCdpPath } from "./cdp.helpers.js";
1011
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
1112
import {
@@ -83,10 +84,13 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<Chro
8384
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
8485
try {
8586
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
86-
const res = await fetch(versionUrl, {
87-
signal: ctrl.signal,
88-
headers: getHeadersWithAuth(versionUrl),
89-
});
87+
// Bypass proxy for loopback CDP connections (#31219)
88+
const res = await withNoProxyForLocalhost(() =>
89+
fetch(versionUrl, {
90+
signal: ctrl.signal,
91+
headers: getHeadersWithAuth(versionUrl),
92+
}),
93+
);
9094
if (!res.ok) {
9195
return null;
9296
}
@@ -117,9 +121,12 @@ export async function getChromeWebSocketUrl(
117121
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
118122
return await new Promise<boolean>((resolve) => {
119123
const headers = getHeadersWithAuth(wsUrl);
124+
// Bypass proxy for loopback CDP connections (#31219)
125+
const wsAgent = getDirectAgentForCdp(wsUrl);
120126
const ws = new WebSocket(wsUrl, {
121127
handshakeTimeout: timeoutMs,
122128
...(Object.keys(headers).length ? { headers } : {}),
129+
...(wsAgent ? { agent: wsAgent } : {}),
123130
});
124131
const timer = setTimeout(
125132
() => {

src/browser/pw-session.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import { chromium } from "playwright-core";
1010
import { formatErrorMessage } from "../infra/errors.js";
1111
import type { SsrFPolicy } from "../infra/net/ssrf.js";
12+
import { withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
1213
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
1314
import { normalizeCdpWsUrl } from "./cdp.js";
1415
import { getChromeWebSocketUrl } from "./chrome.js";
@@ -336,7 +337,10 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
336337
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
337338
const endpoint = wsUrl ?? normalized;
338339
const headers = getHeadersWithAuth(endpoint);
339-
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
340+
// Bypass proxy for loopback CDP connections (#31219)
341+
const browser = await withNoProxyForLocalhost(() =>
342+
chromium.connectOverCDP(endpoint, { timeout, headers }),
343+
);
340344
const onDisconnected = () => {
341345
if (cached?.browser === browser) {
342346
cached = null;

0 commit comments

Comments
 (0)