/ utils / proxy.ts
proxy.ts
  1  // @aws-sdk/credential-provider-node and @smithy/node-http-handler are imported
  2  // dynamically in getAWSClientProxyConfig() to defer ~929KB of AWS SDK.
  3  // undici is lazy-required inside getProxyAgent/configureGlobalAgents to defer
  4  // ~1.5MB when no HTTPS_PROXY/mTLS env vars are set (the common case).
  5  import axios, { type AxiosInstance } from 'axios'
  6  import type { LookupOptions } from 'dns'
  7  import type { Agent } from 'http'
  8  import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent'
  9  import memoize from 'lodash-es/memoize.js'
 10  import type * as undici from 'undici'
 11  import { getCACertificates } from './caCerts.js'
 12  import { logForDebugging } from './debug.js'
 13  import { isEnvTruthy } from './envUtils.js'
 14  import {
 15    getMTLSAgent,
 16    getMTLSConfig,
 17    getTLSFetchOptions,
 18    type TLSConfig,
 19  } from './mtls.js'
 20  
 21  // Disable fetch keep-alive after a stale-pool ECONNRESET so retries open a
 22  // fresh TCP connection instead of reusing the dead pooled socket. Sticky for
 23  // the process lifetime — once the pool is known-bad, don't trust it again.
 24  // Works under Bun (native fetch respects keepalive:false for pooling).
 25  // Under Node/undici, keepalive is a no-op for pooling, but undici
 26  // naturally evicts dead sockets from the pool on ECONNRESET.
 27  let keepAliveDisabled = false
 28  
 29  export function disableKeepAlive(): void {
 30    keepAliveDisabled = true
 31  }
 32  
 33  export function _resetKeepAliveForTesting(): void {
 34    keepAliveDisabled = false
 35  }
 36  
 37  /**
 38   * Convert dns.LookupOptions.family to a numeric address family value
 39   * Handles: 0 | 4 | 6 | 'IPv4' | 'IPv6' | undefined
 40   */
 41  export function getAddressFamily(options: LookupOptions): 0 | 4 | 6 {
 42    switch (options.family) {
 43      case 0:
 44      case 4:
 45      case 6:
 46        return options.family
 47      case 'IPv6':
 48        return 6
 49      case 'IPv4':
 50      case undefined:
 51        return 4
 52      default:
 53        throw new Error(`Unsupported address family: ${options.family}`)
 54    }
 55  }
 56  
 57  type EnvLike = Record<string, string | undefined>
 58  
 59  /**
 60   * Get the active proxy URL if one is configured
 61   * Prefers lowercase variants over uppercase (https_proxy > HTTPS_PROXY > http_proxy > HTTP_PROXY)
 62   * @param env Environment variables to check (defaults to process.env for production use)
 63   */
 64  export function getProxyUrl(env: EnvLike = process.env): string | undefined {
 65    return env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY
 66  }
 67  
 68  /**
 69   * Get the NO_PROXY environment variable value
 70   * Prefers lowercase over uppercase (no_proxy > NO_PROXY)
 71   * @param env Environment variables to check (defaults to process.env for production use)
 72   */
 73  export function getNoProxy(env: EnvLike = process.env): string | undefined {
 74    return env.no_proxy || env.NO_PROXY
 75  }
 76  
 77  /**
 78   * Check if a URL should bypass the proxy based on NO_PROXY environment variable
 79   * Supports:
 80   * - Exact hostname matches (e.g., "localhost")
 81   * - Domain suffix matches with leading dot (e.g., ".example.com")
 82   * - Wildcard "*" to bypass all
 83   * - Port-specific matches (e.g., "example.com:8080")
 84   * - IP addresses (e.g., "127.0.0.1")
 85   * @param urlString URL to check
 86   * @param noProxy NO_PROXY value (defaults to getNoProxy() for production use)
 87   */
 88  export function shouldBypassProxy(
 89    urlString: string,
 90    noProxy: string | undefined = getNoProxy(),
 91  ): boolean {
 92    if (!noProxy) return false
 93  
 94    // Handle wildcard
 95    if (noProxy === '*') return true
 96  
 97    try {
 98      const url = new URL(urlString)
 99      const hostname = url.hostname.toLowerCase()
100      const port = url.port || (url.protocol === 'https:' ? '443' : '80')
101      const hostWithPort = `${hostname}:${port}`
102  
103      // Split by comma or space and trim each entry
104      const noProxyList = noProxy.split(/[,\s]+/).filter(Boolean)
105  
106      return noProxyList.some(pattern => {
107        pattern = pattern.toLowerCase().trim()
108  
109        // Check for port-specific match
110        if (pattern.includes(':')) {
111          return hostWithPort === pattern
112        }
113  
114        // Check for domain suffix match (with or without leading dot)
115        if (pattern.startsWith('.')) {
116          // Pattern ".example.com" should match "sub.example.com" and "example.com"
117          // but NOT "notexample.com"
118          const suffix = pattern
119          return hostname === pattern.substring(1) || hostname.endsWith(suffix)
120        }
121  
122        // Check for exact hostname match or IP address
123        return hostname === pattern
124      })
125    } catch {
126      // If URL parsing fails, don't bypass proxy
127      return false
128    }
129  }
130  
131  /**
132   * Create an HttpsProxyAgent with optional mTLS configuration
133   * Skips local DNS resolution to let the proxy handle it
134   */
135  function createHttpsProxyAgent(
136    proxyUrl: string,
137    extra: HttpsProxyAgentOptions<string> = {},
138  ): HttpsProxyAgent<string> {
139    const mtlsConfig = getMTLSConfig()
140    const caCerts = getCACertificates()
141  
142    const agentOptions: HttpsProxyAgentOptions<string> = {
143      ...(mtlsConfig && {
144        cert: mtlsConfig.cert,
145        key: mtlsConfig.key,
146        passphrase: mtlsConfig.passphrase,
147      }),
148      ...(caCerts && { ca: caCerts }),
149    }
150  
151    if (isEnvTruthy(process.env.CLAUDE_CODE_PROXY_RESOLVES_HOSTS)) {
152      // Skip local DNS resolution - let the proxy resolve hostnames
153      // This is needed for environments where DNS is not configured locally
154      // and instead handled by the proxy (as in sandboxes)
155      agentOptions.lookup = (hostname, options, callback) => {
156        callback(null, hostname, getAddressFamily(options))
157      }
158    }
159  
160    return new HttpsProxyAgent(proxyUrl, { ...agentOptions, ...extra })
161  }
162  
163  /**
164   * Axios instance with its own proxy agent. Same NO_PROXY/mTLS/CA
165   * resolution as the global interceptor, but agent options stay
166   * scoped to this instance.
167   */
168  export function createAxiosInstance(
169    extra: HttpsProxyAgentOptions<string> = {},
170  ): AxiosInstance {
171    const proxyUrl = getProxyUrl()
172    const mtlsAgent = getMTLSAgent()
173    const instance = axios.create({ proxy: false })
174  
175    if (!proxyUrl) {
176      if (mtlsAgent) instance.defaults.httpsAgent = mtlsAgent
177      return instance
178    }
179  
180    const proxyAgent = createHttpsProxyAgent(proxyUrl, extra)
181    instance.interceptors.request.use(config => {
182      if (config.url && shouldBypassProxy(config.url)) {
183        config.httpsAgent = mtlsAgent
184        config.httpAgent = mtlsAgent
185      } else {
186        config.httpsAgent = proxyAgent
187        config.httpAgent = proxyAgent
188      }
189      return config
190    })
191    return instance
192  }
193  
194  /**
195   * Get or create a memoized proxy agent for the given URI
196   * Now respects NO_PROXY environment variable
197   */
198  export const getProxyAgent = memoize((uri: string): undici.Dispatcher => {
199    // eslint-disable-next-line @typescript-eslint/no-require-imports
200    const undiciMod = require('undici') as typeof undici
201    const mtlsConfig = getMTLSConfig()
202    const caCerts = getCACertificates()
203  
204    // Use EnvHttpProxyAgent to respect NO_PROXY
205    // This agent automatically checks NO_PROXY for each request
206    const proxyOptions: undici.EnvHttpProxyAgent.Options & {
207      requestTls?: {
208        cert?: string | Buffer
209        key?: string | Buffer
210        passphrase?: string
211        ca?: string | string[] | Buffer
212      }
213    } = {
214      // Override both HTTP and HTTPS proxy with the provided URI
215      httpProxy: uri,
216      httpsProxy: uri,
217      noProxy: process.env.NO_PROXY || process.env.no_proxy,
218    }
219  
220    // Set both connect and requestTls so TLS options apply to both paths:
221    // - requestTls: used by ProxyAgent for the TLS connection through CONNECT tunnels
222    // - connect: used by Agent for direct (no-proxy) connections
223    if (mtlsConfig || caCerts) {
224      const tlsOpts = {
225        ...(mtlsConfig && {
226          cert: mtlsConfig.cert,
227          key: mtlsConfig.key,
228          passphrase: mtlsConfig.passphrase,
229        }),
230        ...(caCerts && { ca: caCerts }),
231      }
232      proxyOptions.connect = tlsOpts
233      proxyOptions.requestTls = tlsOpts
234    }
235  
236    return new undiciMod.EnvHttpProxyAgent(proxyOptions)
237  })
238  
239  /**
240   * Get an HTTP agent configured for WebSocket proxy support
241   * Returns undefined if no proxy is configured or URL should bypass proxy
242   */
243  export function getWebSocketProxyAgent(url: string): Agent | undefined {
244    const proxyUrl = getProxyUrl()
245  
246    if (!proxyUrl) {
247      return undefined
248    }
249  
250    // Check if URL should bypass proxy
251    if (shouldBypassProxy(url)) {
252      return undefined
253    }
254  
255    return createHttpsProxyAgent(proxyUrl)
256  }
257  
258  /**
259   * Get the proxy URL for WebSocket connections under Bun.
260   * Bun's native WebSocket supports a `proxy` string option instead of Node's `agent`.
261   * Returns undefined if no proxy is configured or URL should bypass proxy.
262   */
263  export function getWebSocketProxyUrl(url: string): string | undefined {
264    const proxyUrl = getProxyUrl()
265  
266    if (!proxyUrl) {
267      return undefined
268    }
269  
270    if (shouldBypassProxy(url)) {
271      return undefined
272    }
273  
274    return proxyUrl
275  }
276  
277  /**
278   * Get fetch options for the Anthropic SDK with proxy and mTLS configuration
279   * Returns fetch options with appropriate dispatcher for proxy and/or mTLS
280   *
281   * @param opts.forAnthropicAPI - Enables ANTHROPIC_UNIX_SOCKET tunneling. This
282   *   env var is set by `claude ssh` on the remote CLI to route API calls through
283   *   an ssh -R forwarded unix socket to a local auth proxy. It MUST NOT leak
284   *   into non-Anthropic-API fetch paths (MCP HTTP/SSE transports, etc.) or those
285   *   requests get misrouted to api.anthropic.com. Only the Anthropic SDK client
286   *   should pass `true` here.
287   */
288  export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): {
289    tls?: TLSConfig
290    dispatcher?: undici.Dispatcher
291    proxy?: string
292    unix?: string
293    keepalive?: false
294  } {
295    const base = keepAliveDisabled ? ({ keepalive: false } as const) : {}
296  
297    // ANTHROPIC_UNIX_SOCKET tunnels through the `claude ssh` auth proxy, which
298    // hardcodes the upstream to the Anthropic API. Scope to the Anthropic API
299    // client so MCP/SSE/other callers don't get their requests misrouted.
300    if (opts?.forAnthropicAPI) {
301      const unixSocket = process.env.ANTHROPIC_UNIX_SOCKET
302      if (unixSocket && typeof Bun !== 'undefined') {
303        return { ...base, unix: unixSocket }
304      }
305    }
306  
307    const proxyUrl = getProxyUrl()
308  
309    // If we have a proxy, use the proxy agent (which includes mTLS config)
310    if (proxyUrl) {
311      if (typeof Bun !== 'undefined') {
312        return { ...base, proxy: proxyUrl, ...getTLSFetchOptions() }
313      }
314      return { ...base, dispatcher: getProxyAgent(proxyUrl) }
315    }
316  
317    // Otherwise, use TLS options directly if available
318    return { ...base, ...getTLSFetchOptions() }
319  }
320  
321  /**
322   * Configure global HTTP agents for both axios and undici
323   * This ensures all HTTP requests use the proxy and/or mTLS if configured
324   */
325  let proxyInterceptorId: number | undefined
326  
327  export function configureGlobalAgents(): void {
328    const proxyUrl = getProxyUrl()
329    const mtlsAgent = getMTLSAgent()
330  
331    // Eject previous interceptor to avoid stacking on repeated calls
332    if (proxyInterceptorId !== undefined) {
333      axios.interceptors.request.eject(proxyInterceptorId)
334      proxyInterceptorId = undefined
335    }
336  
337    // Reset proxy-related defaults so reconfiguration is clean
338    axios.defaults.proxy = undefined
339    axios.defaults.httpAgent = undefined
340    axios.defaults.httpsAgent = undefined
341  
342    if (proxyUrl) {
343      // workaround for https://github.com/axios/axios/issues/4531
344      axios.defaults.proxy = false
345  
346      // Create proxy agent with mTLS options if available
347      const proxyAgent = createHttpsProxyAgent(proxyUrl)
348  
349      // Add axios request interceptor to handle NO_PROXY
350      proxyInterceptorId = axios.interceptors.request.use(config => {
351        // Check if URL should bypass proxy based on NO_PROXY
352        if (config.url && shouldBypassProxy(config.url)) {
353          // Bypass proxy - use mTLS agent if configured, otherwise undefined
354          if (mtlsAgent) {
355            config.httpsAgent = mtlsAgent
356            config.httpAgent = mtlsAgent
357          } else {
358            // Remove any proxy agents to use direct connection
359            delete config.httpsAgent
360            delete config.httpAgent
361          }
362        } else {
363          // Use proxy agent
364          config.httpsAgent = proxyAgent
365          config.httpAgent = proxyAgent
366        }
367        return config
368      })
369  
370      // Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent
371      // eslint-disable-next-line @typescript-eslint/no-require-imports
372      ;(require('undici') as typeof undici).setGlobalDispatcher(
373        getProxyAgent(proxyUrl),
374      )
375    } else if (mtlsAgent) {
376      // No proxy but mTLS is configured
377      axios.defaults.httpsAgent = mtlsAgent
378  
379      // Set undici global dispatcher with mTLS
380      const mtlsOptions = getTLSFetchOptions()
381      if (mtlsOptions.dispatcher) {
382        // eslint-disable-next-line @typescript-eslint/no-require-imports
383        ;(require('undici') as typeof undici).setGlobalDispatcher(
384          mtlsOptions.dispatcher,
385        )
386      }
387    }
388  }
389  
390  /**
391   * Get AWS SDK client configuration with proxy support
392   * Returns configuration object that can be spread into AWS service client constructors
393   */
394  export async function getAWSClientProxyConfig(): Promise<object> {
395    const proxyUrl = getProxyUrl()
396  
397    if (!proxyUrl) {
398      return {}
399    }
400  
401    const [{ NodeHttpHandler }, { defaultProvider }] = await Promise.all([
402      import('@smithy/node-http-handler'),
403      import('@aws-sdk/credential-provider-node'),
404    ])
405  
406    const agent = createHttpsProxyAgent(proxyUrl)
407    const requestHandler = new NodeHttpHandler({
408      httpAgent: agent,
409      httpsAgent: agent,
410    })
411  
412    return {
413      requestHandler,
414      credentials: defaultProvider({
415        clientConfig: { requestHandler },
416      }),
417    }
418  }
419  
420  /**
421   * Clear proxy agent cache.
422   */
423  export function clearProxyCache(): void {
424    getProxyAgent.cache.clear?.()
425    logForDebugging('Cleared proxy agent cache')
426  }