init
This commit is contained in:
+426
@@ -0,0 +1,426 @@
|
||||
// @aws-sdk/credential-provider-node and @smithy/node-http-handler are imported
|
||||
// dynamically in getAWSClientProxyConfig() to defer ~929KB of AWS SDK.
|
||||
// undici is lazy-required inside getProxyAgent/configureGlobalAgents to defer
|
||||
// ~1.5MB when no HTTPS_PROXY/mTLS env vars are set (the common case).
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import type { LookupOptions } from 'dns'
|
||||
import type { Agent } from 'http'
|
||||
import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import type * as undici from 'undici'
|
||||
import { getCACertificates } from './caCerts.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import {
|
||||
getMTLSAgent,
|
||||
getMTLSConfig,
|
||||
getTLSFetchOptions,
|
||||
type TLSConfig,
|
||||
} from './mtls.js'
|
||||
|
||||
// Disable fetch keep-alive after a stale-pool ECONNRESET so retries open a
|
||||
// fresh TCP connection instead of reusing the dead pooled socket. Sticky for
|
||||
// the process lifetime — once the pool is known-bad, don't trust it again.
|
||||
// Works under Bun (native fetch respects keepalive:false for pooling).
|
||||
// Under Node/undici, keepalive is a no-op for pooling, but undici
|
||||
// naturally evicts dead sockets from the pool on ECONNRESET.
|
||||
let keepAliveDisabled = false
|
||||
|
||||
export function disableKeepAlive(): void {
|
||||
keepAliveDisabled = true
|
||||
}
|
||||
|
||||
export function _resetKeepAliveForTesting(): void {
|
||||
keepAliveDisabled = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert dns.LookupOptions.family to a numeric address family value
|
||||
* Handles: 0 | 4 | 6 | 'IPv4' | 'IPv6' | undefined
|
||||
*/
|
||||
export function getAddressFamily(options: LookupOptions): 0 | 4 | 6 {
|
||||
switch (options.family) {
|
||||
case 0:
|
||||
case 4:
|
||||
case 6:
|
||||
return options.family
|
||||
case 'IPv6':
|
||||
return 6
|
||||
case 'IPv4':
|
||||
case undefined:
|
||||
return 4
|
||||
default:
|
||||
throw new Error(`Unsupported address family: ${options.family}`)
|
||||
}
|
||||
}
|
||||
|
||||
type EnvLike = Record<string, string | undefined>
|
||||
|
||||
/**
|
||||
* Get the active proxy URL if one is configured
|
||||
* Prefers lowercase variants over uppercase (https_proxy > HTTPS_PROXY > http_proxy > HTTP_PROXY)
|
||||
* @param env Environment variables to check (defaults to process.env for production use)
|
||||
*/
|
||||
export function getProxyUrl(env: EnvLike = process.env): string | undefined {
|
||||
return env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the NO_PROXY environment variable value
|
||||
* Prefers lowercase over uppercase (no_proxy > NO_PROXY)
|
||||
* @param env Environment variables to check (defaults to process.env for production use)
|
||||
*/
|
||||
export function getNoProxy(env: EnvLike = process.env): string | undefined {
|
||||
return env.no_proxy || env.NO_PROXY
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL should bypass the proxy based on NO_PROXY environment variable
|
||||
* Supports:
|
||||
* - Exact hostname matches (e.g., "localhost")
|
||||
* - Domain suffix matches with leading dot (e.g., ".example.com")
|
||||
* - Wildcard "*" to bypass all
|
||||
* - Port-specific matches (e.g., "example.com:8080")
|
||||
* - IP addresses (e.g., "127.0.0.1")
|
||||
* @param urlString URL to check
|
||||
* @param noProxy NO_PROXY value (defaults to getNoProxy() for production use)
|
||||
*/
|
||||
export function shouldBypassProxy(
|
||||
urlString: string,
|
||||
noProxy: string | undefined = getNoProxy(),
|
||||
): boolean {
|
||||
if (!noProxy) return false
|
||||
|
||||
// Handle wildcard
|
||||
if (noProxy === '*') return true
|
||||
|
||||
try {
|
||||
const url = new URL(urlString)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const port = url.port || (url.protocol === 'https:' ? '443' : '80')
|
||||
const hostWithPort = `${hostname}:${port}`
|
||||
|
||||
// Split by comma or space and trim each entry
|
||||
const noProxyList = noProxy.split(/[,\s]+/).filter(Boolean)
|
||||
|
||||
return noProxyList.some(pattern => {
|
||||
pattern = pattern.toLowerCase().trim()
|
||||
|
||||
// Check for port-specific match
|
||||
if (pattern.includes(':')) {
|
||||
return hostWithPort === pattern
|
||||
}
|
||||
|
||||
// Check for domain suffix match (with or without leading dot)
|
||||
if (pattern.startsWith('.')) {
|
||||
// Pattern ".example.com" should match "sub.example.com" and "example.com"
|
||||
// but NOT "notexample.com"
|
||||
const suffix = pattern
|
||||
return hostname === pattern.substring(1) || hostname.endsWith(suffix)
|
||||
}
|
||||
|
||||
// Check for exact hostname match or IP address
|
||||
return hostname === pattern
|
||||
})
|
||||
} catch {
|
||||
// If URL parsing fails, don't bypass proxy
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HttpsProxyAgent with optional mTLS configuration
|
||||
* Skips local DNS resolution to let the proxy handle it
|
||||
*/
|
||||
function createHttpsProxyAgent(
|
||||
proxyUrl: string,
|
||||
extra: HttpsProxyAgentOptions<string> = {},
|
||||
): HttpsProxyAgent<string> {
|
||||
const mtlsConfig = getMTLSConfig()
|
||||
const caCerts = getCACertificates()
|
||||
|
||||
const agentOptions: HttpsProxyAgentOptions<string> = {
|
||||
...(mtlsConfig && {
|
||||
cert: mtlsConfig.cert,
|
||||
key: mtlsConfig.key,
|
||||
passphrase: mtlsConfig.passphrase,
|
||||
}),
|
||||
...(caCerts && { ca: caCerts }),
|
||||
}
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_PROXY_RESOLVES_HOSTS)) {
|
||||
// Skip local DNS resolution - let the proxy resolve hostnames
|
||||
// This is needed for environments where DNS is not configured locally
|
||||
// and instead handled by the proxy (as in sandboxes)
|
||||
agentOptions.lookup = (hostname, options, callback) => {
|
||||
callback(null, hostname, getAddressFamily(options))
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpsProxyAgent(proxyUrl, { ...agentOptions, ...extra })
|
||||
}
|
||||
|
||||
/**
|
||||
* Axios instance with its own proxy agent. Same NO_PROXY/mTLS/CA
|
||||
* resolution as the global interceptor, but agent options stay
|
||||
* scoped to this instance.
|
||||
*/
|
||||
export function createAxiosInstance(
|
||||
extra: HttpsProxyAgentOptions<string> = {},
|
||||
): AxiosInstance {
|
||||
const proxyUrl = getProxyUrl()
|
||||
const mtlsAgent = getMTLSAgent()
|
||||
const instance = axios.create({ proxy: false })
|
||||
|
||||
if (!proxyUrl) {
|
||||
if (mtlsAgent) instance.defaults.httpsAgent = mtlsAgent
|
||||
return instance
|
||||
}
|
||||
|
||||
const proxyAgent = createHttpsProxyAgent(proxyUrl, extra)
|
||||
instance.interceptors.request.use(config => {
|
||||
if (config.url && shouldBypassProxy(config.url)) {
|
||||
config.httpsAgent = mtlsAgent
|
||||
config.httpAgent = mtlsAgent
|
||||
} else {
|
||||
config.httpsAgent = proxyAgent
|
||||
config.httpAgent = proxyAgent
|
||||
}
|
||||
return config
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a memoized proxy agent for the given URI
|
||||
* Now respects NO_PROXY environment variable
|
||||
*/
|
||||
export const getProxyAgent = memoize((uri: string): undici.Dispatcher => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const undiciMod = require('undici') as typeof undici
|
||||
const mtlsConfig = getMTLSConfig()
|
||||
const caCerts = getCACertificates()
|
||||
|
||||
// Use EnvHttpProxyAgent to respect NO_PROXY
|
||||
// This agent automatically checks NO_PROXY for each request
|
||||
const proxyOptions: undici.EnvHttpProxyAgent.Options & {
|
||||
requestTls?: {
|
||||
cert?: string | Buffer
|
||||
key?: string | Buffer
|
||||
passphrase?: string
|
||||
ca?: string | string[] | Buffer
|
||||
}
|
||||
} = {
|
||||
// Override both HTTP and HTTPS proxy with the provided URI
|
||||
httpProxy: uri,
|
||||
httpsProxy: uri,
|
||||
noProxy: process.env.NO_PROXY || process.env.no_proxy,
|
||||
}
|
||||
|
||||
// Set both connect and requestTls so TLS options apply to both paths:
|
||||
// - requestTls: used by ProxyAgent for the TLS connection through CONNECT tunnels
|
||||
// - connect: used by Agent for direct (no-proxy) connections
|
||||
if (mtlsConfig || caCerts) {
|
||||
const tlsOpts = {
|
||||
...(mtlsConfig && {
|
||||
cert: mtlsConfig.cert,
|
||||
key: mtlsConfig.key,
|
||||
passphrase: mtlsConfig.passphrase,
|
||||
}),
|
||||
...(caCerts && { ca: caCerts }),
|
||||
}
|
||||
proxyOptions.connect = tlsOpts
|
||||
proxyOptions.requestTls = tlsOpts
|
||||
}
|
||||
|
||||
return new undiciMod.EnvHttpProxyAgent(proxyOptions)
|
||||
})
|
||||
|
||||
/**
|
||||
* Get an HTTP agent configured for WebSocket proxy support
|
||||
* Returns undefined if no proxy is configured or URL should bypass proxy
|
||||
*/
|
||||
export function getWebSocketProxyAgent(url: string): Agent | undefined {
|
||||
const proxyUrl = getProxyUrl()
|
||||
|
||||
if (!proxyUrl) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Check if URL should bypass proxy
|
||||
if (shouldBypassProxy(url)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return createHttpsProxyAgent(proxyUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the proxy URL for WebSocket connections under Bun.
|
||||
* Bun's native WebSocket supports a `proxy` string option instead of Node's `agent`.
|
||||
* Returns undefined if no proxy is configured or URL should bypass proxy.
|
||||
*/
|
||||
export function getWebSocketProxyUrl(url: string): string | undefined {
|
||||
const proxyUrl = getProxyUrl()
|
||||
|
||||
if (!proxyUrl) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (shouldBypassProxy(url)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return proxyUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fetch options for the Anthropic SDK with proxy and mTLS configuration
|
||||
* Returns fetch options with appropriate dispatcher for proxy and/or mTLS
|
||||
*
|
||||
* @param opts.forAnthropicAPI - Enables ANTHROPIC_UNIX_SOCKET tunneling. This
|
||||
* env var is set by `claude ssh` on the remote CLI to route API calls through
|
||||
* an ssh -R forwarded unix socket to a local auth proxy. It MUST NOT leak
|
||||
* into non-Anthropic-API fetch paths (MCP HTTP/SSE transports, etc.) or those
|
||||
* requests get misrouted to api.anthropic.com. Only the Anthropic SDK client
|
||||
* should pass `true` here.
|
||||
*/
|
||||
export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): {
|
||||
tls?: TLSConfig
|
||||
dispatcher?: undici.Dispatcher
|
||||
proxy?: string
|
||||
unix?: string
|
||||
keepalive?: false
|
||||
} {
|
||||
const base = keepAliveDisabled ? ({ keepalive: false } as const) : {}
|
||||
|
||||
// ANTHROPIC_UNIX_SOCKET tunnels through the `claude ssh` auth proxy, which
|
||||
// hardcodes the upstream to the Anthropic API. Scope to the Anthropic API
|
||||
// client so MCP/SSE/other callers don't get their requests misrouted.
|
||||
if (opts?.forAnthropicAPI) {
|
||||
const unixSocket = process.env.ANTHROPIC_UNIX_SOCKET
|
||||
if (unixSocket && typeof Bun !== 'undefined') {
|
||||
return { ...base, unix: unixSocket }
|
||||
}
|
||||
}
|
||||
|
||||
const proxyUrl = getProxyUrl()
|
||||
|
||||
// If we have a proxy, use the proxy agent (which includes mTLS config)
|
||||
if (proxyUrl) {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return { ...base, proxy: proxyUrl, ...getTLSFetchOptions() }
|
||||
}
|
||||
return { ...base, dispatcher: getProxyAgent(proxyUrl) }
|
||||
}
|
||||
|
||||
// Otherwise, use TLS options directly if available
|
||||
return { ...base, ...getTLSFetchOptions() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure global HTTP agents for both axios and undici
|
||||
* This ensures all HTTP requests use the proxy and/or mTLS if configured
|
||||
*/
|
||||
let proxyInterceptorId: number | undefined
|
||||
|
||||
export function configureGlobalAgents(): void {
|
||||
const proxyUrl = getProxyUrl()
|
||||
const mtlsAgent = getMTLSAgent()
|
||||
|
||||
// Eject previous interceptor to avoid stacking on repeated calls
|
||||
if (proxyInterceptorId !== undefined) {
|
||||
axios.interceptors.request.eject(proxyInterceptorId)
|
||||
proxyInterceptorId = undefined
|
||||
}
|
||||
|
||||
// Reset proxy-related defaults so reconfiguration is clean
|
||||
axios.defaults.proxy = undefined
|
||||
axios.defaults.httpAgent = undefined
|
||||
axios.defaults.httpsAgent = undefined
|
||||
|
||||
if (proxyUrl) {
|
||||
// workaround for https://github.com/axios/axios/issues/4531
|
||||
axios.defaults.proxy = false
|
||||
|
||||
// Create proxy agent with mTLS options if available
|
||||
const proxyAgent = createHttpsProxyAgent(proxyUrl)
|
||||
|
||||
// Add axios request interceptor to handle NO_PROXY
|
||||
proxyInterceptorId = axios.interceptors.request.use(config => {
|
||||
// Check if URL should bypass proxy based on NO_PROXY
|
||||
if (config.url && shouldBypassProxy(config.url)) {
|
||||
// Bypass proxy - use mTLS agent if configured, otherwise undefined
|
||||
if (mtlsAgent) {
|
||||
config.httpsAgent = mtlsAgent
|
||||
config.httpAgent = mtlsAgent
|
||||
} else {
|
||||
// Remove any proxy agents to use direct connection
|
||||
delete config.httpsAgent
|
||||
delete config.httpAgent
|
||||
}
|
||||
} else {
|
||||
// Use proxy agent
|
||||
config.httpsAgent = proxyAgent
|
||||
config.httpAgent = proxyAgent
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
;(require('undici') as typeof undici).setGlobalDispatcher(
|
||||
getProxyAgent(proxyUrl),
|
||||
)
|
||||
} else if (mtlsAgent) {
|
||||
// No proxy but mTLS is configured
|
||||
axios.defaults.httpsAgent = mtlsAgent
|
||||
|
||||
// Set undici global dispatcher with mTLS
|
||||
const mtlsOptions = getTLSFetchOptions()
|
||||
if (mtlsOptions.dispatcher) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
;(require('undici') as typeof undici).setGlobalDispatcher(
|
||||
mtlsOptions.dispatcher,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AWS SDK client configuration with proxy support
|
||||
* Returns configuration object that can be spread into AWS service client constructors
|
||||
*/
|
||||
export async function getAWSClientProxyConfig(): Promise<object> {
|
||||
const proxyUrl = getProxyUrl()
|
||||
|
||||
if (!proxyUrl) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const [{ NodeHttpHandler }, { defaultProvider }] = await Promise.all([
|
||||
import('@smithy/node-http-handler'),
|
||||
import('@aws-sdk/credential-provider-node'),
|
||||
])
|
||||
|
||||
const agent = createHttpsProxyAgent(proxyUrl)
|
||||
const requestHandler = new NodeHttpHandler({
|
||||
httpAgent: agent,
|
||||
httpsAgent: agent,
|
||||
})
|
||||
|
||||
return {
|
||||
requestHandler,
|
||||
credentials: defaultProvider({
|
||||
clientConfig: { requestHandler },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear proxy agent cache.
|
||||
*/
|
||||
export function clearProxyCache(): void {
|
||||
getProxyAgent.cache.clear?.()
|
||||
logForDebugging('Cleared proxy agent cache')
|
||||
}
|
||||
Reference in New Issue
Block a user