diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c88efeda..93b736491 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,6 +249,34 @@ content: serialized headers may quote or escape filenames, and parsed patches can omit filenames. Check the diff and edit-snippet tests when changing these paths. +### Proxy agents + +Use the shared helpers in `src/utils/proxy.ts`: `createAxiosInstance()` for +Axios, `getWebSocketProxyAgent(url)` for Node WebSockets, and `getProxyFetchOptions()` +for fetch. The Axios and WebSocket helpers apply `NO_PROXY` bypasses before +selecting an agent. `getWebSocketProxyUrl(url)` supplies Bun's native WebSocket proxy +option. Lowercase proxy environment variables take precedence over uppercase +variants on the Axios and WebSocket paths. + +`https-proxy-agent` is pinned exactly to `9.1.0` and remains an external runtime +module in `scripts/externals.ts`. Use its public named ESM exports, such as +`import { HttpsProxyAgent } from 'https-proxy-agent'`, and the constructor +`new HttpsProxyAgent(proxyUrl, options)`. Both it and `agent-base` are ESM +packages requiring Node.js 20 or later; use OpenClaude's supported Node.js +`>=24.22.1` runtime. Keep dependency updates exact and generate `bun.lock` with +`bun add --exact https-proxy-agent@`. + +Keep TLS verification enabled. Configure `NODE_EXTRA_CA_CERTS` before starting +the CLI when a custom CA is needed. For runtime certificate configuration, the +shared CA loader reads that file and the TLS helpers supply explicit options; +clear the CA and mTLS caches after configuration changes. Proxy-agent +constructor options configure the connection to the proxy. Supply target TLS +options on the HTTPS request, using `getWebSocketTLSOptions()` where applicable; +do not assume constructor `ca` options also trust the target inside a CONNECT +tunnel. Run `bun test src/utils/proxy.connect.test.ts src/utils/proxy.test.ts` +when changing these paths to verify HTTPS CONNECT routing and `NO_PROXY` +bypass with certificate validation enabled. + ## Provider Changes OpenClaude supports multiple provider paths. Before contributing provider changes, review the relevant documentation to ensure your implementation follows the expected patterns: diff --git a/bun.lock b/bun.lock index c42072af5..443906786 100644 --- a/bun.lock +++ b/bun.lock @@ -54,7 +54,7 @@ "google-auth-library": "10.6.2", "graphology": "0.26.0", "graphology-metrics": "2.4.2", - "https-proxy-agent": "7.0.6", + "https-proxy-agent": "9.1.0", "ignore": "7.0.13", "indent-string": "5.0.0", "js-tiktoken": "1.0.21", @@ -597,7 +597,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -877,7 +877,7 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -1089,6 +1089,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1487,6 +1489,8 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "bun-types/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -1513,8 +1517,12 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "get-intrinsic/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "knip/zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "needle/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1561,6 +1569,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@typespec/ts-http-runtime/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1589,6 +1599,8 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], diff --git a/package.json b/package.json index 7659b5a9b..4c19f5c18 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "google-auth-library": "10.6.2", "graphology": "0.26.0", "graphology-metrics": "2.4.2", - "https-proxy-agent": "7.0.6", + "https-proxy-agent": "9.1.0", "ignore": "7.0.13", "indent-string": "5.0.0", "js-tiktoken": "1.0.21", diff --git a/src/utils/proxy.connect.test.ts b/src/utils/proxy.connect.test.ts new file mode 100644 index 000000000..596acd37b --- /dev/null +++ b/src/utils/proxy.connect.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { once } from 'node:events' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer as createHttpServer, type IncomingMessage } from 'node:http' +import { + createServer as createHttpsServer, + get, + request, + type RequestOptions, +} from 'node:https' +import { connect, type AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Duplex } from 'node:stream' +import { snapshotEnv } from '../test/envSnapshot.js' +import { clearCACertsCache } from './caCerts.js' +import { clearMTLSCache, getMTLSAgent, getWebSocketTLSOptions } from './mtls.js' + +// Cache-busting proxy.ts alone still imports any leaked global Axios mock. +// Build a fresh copy of the production module with only its Axios import +// redirected to the real CommonJS entry. Keep the other imports external so +// CA/mTLS caches stay shared, without changing any global mock registration. +const nonce = `${Date.now()}-${Math.random()}` +const proxyBuild = await Bun.build({ + entrypoints: [join(import.meta.dir, 'proxy.ts')], + target: 'bun', + plugins: [{ + name: 'connect-test-real-axios', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + if (!args.importer) return + return { + path: args.path === 'axios' + ? `${require.resolve('axios')}?real=${nonce}` + : Bun.resolveSync(args.path, args.resolveDir), + external: true, + } + }) + }, + }], +}) +if (!proxyBuild.success) { + throw new AggregateError(proxyBuild.logs, 'Could not build proxy.ts for CONNECT tests') +} +const { createAxiosInstance, getWebSocketProxyAgent } = await import( + `data:text/javascript;base64,${Buffer.from(await proxyBuild.outputs[0].text()).toString('base64')}` +) as typeof import('./proxy.js') + +// Self-signed test certificate and key, valid only for the loopback target. +const cert = `-----BEGIN CERTIFICATE----- +MIIBmzCCAUGgAwIBAgIUAtOYg4xV7C9catkY/bwZc7XywMgwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAyNDA4NTUxNloYDzIxMjYwOTMw +MDg1NTE2WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAS5HiqEES/b1Oe+NOKT+Cg3Gh89Rk0O7bSu0aDJpwdMzOxgOT2jTBmK +2H5TUBnDWHjLktPtbmQQ4nMAb1kD4oKWo28wbTAdBgNVHQ4EFgQUu+xqnGfB/gu8 +DbNNHlVonV69Y64wHwYDVR0jBBgwFoAUu+xqnGfB/gu8DbNNHlVonV69Y64wDwYD +VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZI +zj0EAwIDSAAwRQIgPy5jbT1ECH7LFy5r5JrV7xfsOkhwtbuwqVeidDBynFACIQCp +sTyTz0llkGsTStOnpDBTzTUQzcDT8z6ZgqqxbY29nw== +-----END CERTIFICATE-----` +const key = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXc/iw7bBMrZkWHsr +tDvMDJ6qPLsspu6IH8lHZ3ccGvihRANCAAS5HiqEES/b1Oe+NOKT+Cg3Gh89Rk0O +7bSu0aDJpwdMzOxgOT2jTBmK2H5TUBnDWHjLktPtbmQQ4nMAb1kD4oKW +-----END PRIVATE KEY-----` + +describe('HTTPS_PROXY CONNECT transport', () => { + let restoreEnv: () => void + let directory: string + const sockets = new Set() + let target: ReturnType + let proxy: ReturnType + let targetUrl: string + let connects: string[] + + beforeEach(async () => { + restoreEnv = snapshotEnv([ + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'NO_PROXY', + 'no_proxy', + 'NODE_EXTRA_CA_CERTS', + 'NODE_OPTIONS', + 'NODE_TLS_REJECT_UNAUTHORIZED', + 'CLAUDE_CODE_CLIENT_CERT', + 'CLAUDE_CODE_CLIENT_KEY', + 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE', + 'CLAUDE_CODE_PROXY_RESOLVES_HOSTS', + ], { clear: true }) + directory = mkdtempSync(join(tmpdir(), 'proxy-connect-')) + const caPath = join(directory, 'ca.pem') + writeFileSync(caPath, cert) + process.env.NODE_EXTRA_CA_CERTS = caPath + clearCACertsCache() + clearMTLSCache() + connects = [] + + target = createHttpsServer({ key, cert }, (_request, response) => { + response.end('secure response') + }) + target.listen(0, '127.0.0.1') + await once(target, 'listening') + const targetPort = (target.address() as AddressInfo).port + targetUrl = `https://127.0.0.1:${targetPort}/test` + + proxy = createHttpServer() + proxy.on('connect', (request, client, head) => { + connects.push(request.url!) + // Forward only to this test's target; assert the CONNECT authority below. + const upstream = connect(targetPort, '127.0.0.1', () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n') + if (head.length) upstream.write(head) + client.pipe(upstream) + upstream.pipe(client) + }) + sockets.add(client) + sockets.add(upstream) + client.on('error', () => upstream.destroy()) + upstream.on('error', () => client.destroy()) + }) + proxy.listen(0, '127.0.0.1') + await once(proxy, 'listening') + process.env.HTTPS_PROXY = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` + }) + + afterEach(async () => { + getMTLSAgent()?.destroy() + for (const socket of sockets) socket.destroy() + sockets.clear() + target?.closeAllConnections() + await Promise.all( + [target, proxy].filter(Boolean).map(server => + new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + }), + ), + ) + restoreEnv() + clearCACertsCache() + clearMTLSCache() + rmSync(directory, { recursive: true, force: true }) + }) + + test('Axios tunnels HTTPS through HTTPS_PROXY and bypasses it for NO_PROXY', async () => { + const client = createAxiosInstance() + // Native TLS reads NODE_EXTRA_CA_CERTS only at startup. Pass the runtime + // CA loader's result to the target request; agent options configure the proxy. + client.defaults.transport = { + request: ( + options: RequestOptions, + callback: (response: IncomingMessage) => void, + ) => request({ ...options, ...getWebSocketTLSOptions() }, callback), + } + const response = await client.get(targetUrl, { timeout: 3000 }) + expect(response.data).toBe('secure response') + expect(connects).toEqual([new URL(targetUrl).host]) + + process.env.NO_PROXY = '127.0.0.1' + const direct = await client.get(targetUrl, { timeout: 3000 }) + expect(direct.data).toBe('secure response') + expect(connects).toHaveLength(1) + }) + + test('WebSocket agent tunnels HTTPS and is omitted for NO_PROXY', async () => { + const agent = getWebSocketProxyAgent(targetUrl.replace('https:', 'wss:')) + expect(agent).toBeDefined() + try { + const body = await new Promise((resolve, reject) => { + const request = get( + targetUrl, + { agent, ...getWebSocketTLSOptions() }, + response => { + response.setEncoding('utf8') + let data = '' + response.on('data', chunk => { data += chunk }) + response.on('end', () => resolve(data)) + response.on('error', reject) + }, + ) + request.on('error', reject) + request.setTimeout(3000, () => + request.destroy(new Error('HTTPS request timed out')), + ) + }) + expect(body).toBe('secure response') + expect(connects).toEqual([new URL(targetUrl).host]) + process.env.NO_PROXY = '127.0.0.1' + expect(getWebSocketProxyAgent(targetUrl.replace('https:', 'wss:'))).toBeUndefined() + } finally { + agent?.destroy() + } + }) +})