File size: 4,343 Bytes
fc93158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
/**
 * 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";
import { hasProxyEnvConfigured } from "../infra/net/proxy-env.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 {
  return hasProxyEnvConfigured();
}

const LOOPBACK_ENTRIES = "localhost,127.0.0.1,[::1]";

function noProxyAlreadyCoversLocalhost(): boolean {
  const current = process.env.NO_PROXY || process.env.no_proxy || "";
  return (
    current.includes("localhost") && current.includes("127.0.0.1") && current.includes("[::1]")
  );
}

export async function withNoProxyForLocalhost<T>(fn: () => Promise<T>): Promise<T> {
  return await withNoProxyForCdpUrl("http://127.0.0.1", fn);
}

function isLoopbackCdpUrl(url: string): boolean {
  try {
    return isLoopbackHost(new URL(url).hostname);
  } catch {
    return false;
  }
}

type NoProxySnapshot = {
  noProxy: string | undefined;
  noProxyLower: string | undefined;
  applied: string;
};

class NoProxyLeaseManager {
  private leaseCount = 0;
  private snapshot: NoProxySnapshot | null = null;

  acquire(url: string): (() => void) | null {
    if (!isLoopbackCdpUrl(url) || !hasProxyEnv()) {
      return null;
    }

    if (this.leaseCount === 0 && !noProxyAlreadyCoversLocalhost()) {
      const noProxy = process.env.NO_PROXY;
      const noProxyLower = process.env.no_proxy;
      const current = noProxy || noProxyLower || "";
      const applied = current ? `${current},${LOOPBACK_ENTRIES}` : LOOPBACK_ENTRIES;
      process.env.NO_PROXY = applied;
      process.env.no_proxy = applied;
      this.snapshot = { noProxy, noProxyLower, applied };
    }

    this.leaseCount += 1;
    let released = false;
    return () => {
      if (released) {
        return;
      }
      released = true;
      this.release();
    };
  }

  private release() {
    if (this.leaseCount <= 0) {
      return;
    }
    this.leaseCount -= 1;
    if (this.leaseCount > 0 || !this.snapshot) {
      return;
    }

    const { noProxy, noProxyLower, applied } = this.snapshot;
    const currentNoProxy = process.env.NO_PROXY;
    const currentNoProxyLower = process.env.no_proxy;
    const untouched =
      currentNoProxy === applied &&
      (currentNoProxyLower === applied || currentNoProxyLower === undefined);
    if (untouched) {
      if (noProxy !== undefined) {
        process.env.NO_PROXY = noProxy;
      } else {
        delete process.env.NO_PROXY;
      }
      if (noProxyLower !== undefined) {
        process.env.no_proxy = noProxyLower;
      } else {
        delete process.env.no_proxy;
      }
    }

    this.snapshot = null;
  }
}

const noProxyLeaseManager = new NoProxyLeaseManager();

/**
 * Scoped NO_PROXY bypass for loopback CDP URLs.
 *
 * This wrapper only mutates env vars for loopback destinations. On restore,
 * it avoids clobbering external NO_PROXY changes that happened while calls
 * were in-flight.
 */
export async function withNoProxyForCdpUrl<T>(url: string, fn: () => Promise<T>): Promise<T> {
  const release = noProxyLeaseManager.acquire(url);
  try {
    return await fn();
  } finally {
    release?.();
  }
}