From b39887b2d30bb01edddabec1a4cdeaa7626a82aa Mon Sep 17 00:00:00 2001 From: IronRookieCoder Date: Tue, 12 May 2026 15:35:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=92=8C=20CA=20=E8=AF=81=E4=B9=A6=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E5=8F=8A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- src/entrypoints/init.ts | 2 + src/utils/__tests__/caCerts.test.ts | 94 ++++++++++++++ src/utils/__tests__/proxy.test.ts | 98 ++++++++++++++ src/utils/caCerts.ts | 64 ++++++--- src/utils/proxy.ts | 195 +++++++++++++++++++++++++++- 5 files changed, 427 insertions(+), 26 deletions(-) create mode 100644 src/utils/__tests__/caCerts.test.ts create mode 100644 src/utils/__tests__/proxy.test.ts diff --git a/src/entrypoints/init.ts b/src/entrypoints/init.ts index b1301056d..9f6032db9 100644 --- a/src/entrypoints/init.ts +++ b/src/entrypoints/init.ts @@ -18,6 +18,7 @@ import { waitForRemoteManagedSettingsToLoad, } from '../services/remoteManagedSettings/index.js' import { preconnectAnthropicApi } from '../utils/apiPreconnect.js' +import { validateExtraCACertsEnv } from '../utils/caCerts.js' import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js' import { registerCleanup } from '../utils/cleanupRegistry.js' import { @@ -91,6 +92,7 @@ export const init = memoize(async (): Promise => { // before any TLS connections. Bun caches the TLS cert store at boot // via BoringSSL, so this must happen before the first TLS handshake. applyExtraCACertsFromConfig() + validateExtraCACertsEnv() logForDiagnosticsNoPII('info', 'init_safe_env_vars_applied', { duration_ms: Date.now() - envVarsStart, diff --git a/src/utils/__tests__/caCerts.test.ts b/src/utils/__tests__/caCerts.test.ts new file mode 100644 index 000000000..f53c8ab15 --- /dev/null +++ b/src/utils/__tests__/caCerts.test.ts @@ -0,0 +1,94 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { debugMock } from "../../../tests/mocks/debug"; + +mock.module("src/utils/debug.ts", debugMock); + +const { clearCACertsCache, getCACertificates, validateExtraCACertsEnv } = + await import("../caCerts"); + +const originalNodeExtraCACerts = process.env.NODE_EXTRA_CA_CERTS; +const originalNodeOptions = process.env.NODE_OPTIONS; + +let tempDir: string; + +describe("getCACertificates", () => { + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "csc-ca-certs-")); + delete process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_OPTIONS; + clearCACertsCache(); + }); + + afterEach(() => { + restoreEnvVar("NODE_EXTRA_CA_CERTS", originalNodeExtraCACerts); + restoreEnvVar("NODE_OPTIONS", originalNodeOptions); + clearCACertsCache(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("ignores missing NODE_EXTRA_CA_CERTS path", () => { + process.env.NODE_EXTRA_CA_CERTS = join(tempDir, "missing.pem"); + + expect(getCACertificates()).toBeUndefined(); + expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + }); + + test("validates and clears missing NODE_EXTRA_CA_CERTS before TLS setup", () => { + process.env.NODE_EXTRA_CA_CERTS = join(tempDir, "missing.pem"); + + validateExtraCACertsEnv(); + + expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + }); + + test("ignores directory NODE_EXTRA_CA_CERTS path", () => { + process.env.NODE_EXTRA_CA_CERTS = tempDir; + + expect(getCACertificates()).toBeUndefined(); + expect(process.env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + }); + + test("appends readable NODE_EXTRA_CA_CERTS file", () => { + const certPath = join(tempDir, "extra.pem"); + const cert = "-----BEGIN CERTIFICATE-----\nextra\n-----END CERTIFICATE-----\n"; + writeFileSync(certPath, cert); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + expect(getCACertificates()).toContain(cert); + expect(process.env.NODE_EXTRA_CA_CERTS).toBe(certPath); + }); + + test("follows NODE_EXTRA_CA_CERTS symlink to a regular file", () => { + if (process.platform === "win32") { + return; + } + + const certPath = join(tempDir, "extra.pem"); + const linkPath = join(tempDir, "extra-link.pem"); + const cert = "-----BEGIN CERTIFICATE-----\nlinked\n-----END CERTIFICATE-----\n"; + writeFileSync(certPath, cert); + symlinkSync(certPath, linkPath); + process.env.NODE_EXTRA_CA_CERTS = linkPath; + + expect(getCACertificates()).toContain(cert); + expect(process.env.NODE_EXTRA_CA_CERTS).toBe(linkPath); + }); +}); + +function restoreEnvVar(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} diff --git a/src/utils/__tests__/proxy.test.ts b/src/utils/__tests__/proxy.test.ts new file mode 100644 index 000000000..5b1c20de8 --- /dev/null +++ b/src/utils/__tests__/proxy.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { createServer } from "node:net"; +import { debugMock } from "../../../tests/mocks/debug"; + +mock.module("src/utils/debug.ts", debugMock); + +const { + _resetProxyReachabilityForTesting, + _waitForProxyReachabilityForTesting, + getProxyUrl, +} = await import("../proxy"); + +const originalProxyEnv = { + https_proxy: process.env.https_proxy, + HTTPS_PROXY: process.env.HTTPS_PROXY, + http_proxy: process.env.http_proxy, + HTTP_PROXY: process.env.HTTP_PROXY, +}; + +describe("getProxyUrl", () => { + afterEach(() => { + restoreProxyEnv(); + _resetProxyReachabilityForTesting(); + }); + + test("does not use an unreachable loopback proxy", async () => { + const port = await getClosedLoopbackPort(); + clearProxyEnv(); + process.env.https_proxy = `http://127.0.0.1:${port}`; + + expect(getProxyUrl()).toBeUndefined(); + + expect(await _waitForProxyReachabilityForTesting()).toBe(false); + + expect(process.env.https_proxy).toBeUndefined(); + expect(getProxyUrl()).toBeUndefined(); + }); + + test("uses a loopback proxy after reachability is confirmed", async () => { + const server = createServer(); + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP server address"); + } + const proxyUrl = `http://127.0.0.1:${address.port}`; + clearProxyEnv(); + process.env.https_proxy = proxyUrl; + + expect(getProxyUrl()).toBeUndefined(); + expect(await _waitForProxyReachabilityForTesting()).toBe(true); + expect(getProxyUrl()).toBe(proxyUrl); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + }); + + test("clears invalid proxy URLs from the current process", () => { + clearProxyEnv(); + process.env.https_proxy = "not-a-url"; + + expect(getProxyUrl()).toBeUndefined(); + expect(process.env.https_proxy).toBeUndefined(); + }); +}); + +function clearProxyEnv(): void { + for (const key of Object.keys(originalProxyEnv)) { + delete process.env[key]; + } +} + +function restoreProxyEnv(): void { + for (const [key, value] of Object.entries(originalProxyEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +async function getClosedLoopbackPort(): Promise { + const server = createServer(); + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP server address"); + } + const port = address.port; + await new Promise(resolve => server.close(() => resolve())); + return port; +} diff --git a/src/utils/caCerts.ts b/src/utils/caCerts.ts index 1974a9357..8e7fffd6b 100644 --- a/src/utils/caCerts.ts +++ b/src/utils/caCerts.ts @@ -29,14 +29,14 @@ export const getCACertificates = memoize((): string[] | undefined => { const useSystemCA = hasNodeOption('--use-system-ca') || hasNodeOption('--use-openssl-ca') - const extraCertsPath = process.env.NODE_EXTRA_CA_CERTS + const extraCert = readExtraCACert(process.env.NODE_EXTRA_CA_CERTS) logForDebugging( - `CA certs: useSystemCA=${useSystemCA}, extraCertsPath=${extraCertsPath}`, + `CA certs: useSystemCA=${useSystemCA}, hasExtraCert=${extraCert !== undefined}`, ) // If neither is set, return undefined (use runtime defaults, no override) - if (!useSystemCA && !extraCertsPath) { + if (!useSystemCA && !extraCert) { return undefined } @@ -61,7 +61,7 @@ export const getCACertificates = memoize((): string[] | undefined => { logForDebugging( `CA certs: Loaded ${certs.length} system CA certificates (--use-system-ca)`, ) - } else if (!getCACerts && !extraCertsPath) { + } else if (!getCACerts && !extraCert) { // Under Node.js where getCACertificates doesn't exist and no extra certs, // return undefined to let Node.js handle --use-system-ca natively. logForDebugging( @@ -84,26 +84,52 @@ export const getCACertificates = memoize((): string[] | undefined => { } // Append extra certs from file - if (extraCertsPath) { - try { - const extraCert = getFsImplementation().readFileSync(extraCertsPath, { - encoding: 'utf8', - }) - certs.push(extraCert) - logForDebugging( - `CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS (${extraCertsPath})`, - ) - } catch (error) { - logForDebugging( - `CA certs: Failed to read NODE_EXTRA_CA_CERTS file (${extraCertsPath}): ${error}`, - { level: 'error' }, - ) - } + if (extraCert) { + certs.push(extraCert) + logForDebugging( + 'CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS', + ) } return certs.length > 0 ? certs : undefined }) +export function validateExtraCACertsEnv(): void { + readExtraCACert(process.env.NODE_EXTRA_CA_CERTS) +} + +function readExtraCACert(path: string | undefined): string | undefined { + if (!path) { + return undefined + } + + try { + const stats = getFsImplementation().statSync(path) + if (!stats.isFile()) { + logForDebugging( + `CA certs: Ignoring NODE_EXTRA_CA_CERTS because it is not a regular file (${path})`, + { level: 'error' }, + ) + clearInvalidExtraCACertsEnv(path) + return undefined + } + return getFsImplementation().readFileSync(path, { encoding: 'utf8' }) + } catch (error) { + logForDebugging( + `CA certs: Ignoring unreadable NODE_EXTRA_CA_CERTS path (${path}): ${error}`, + { level: 'error' }, + ) + clearInvalidExtraCACertsEnv(path) + return undefined + } +} + +function clearInvalidExtraCACertsEnv(path: string): void { + if (process.env.NODE_EXTRA_CA_CERTS === path) { + delete process.env.NODE_EXTRA_CA_CERTS + } +} + /** * Clear the CA certificates cache. * Call this when environment variables that affect CA certs may have changed diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 398802319..3952471ab 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -7,6 +7,7 @@ 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 { connect } from 'net' import type * as undici from 'undici' import { getCACertificates } from './caCerts.js' import { logForDebugging } from './debug.js' @@ -55,6 +56,17 @@ export function getAddressFamily(options: LookupOptions): 0 | 4 | 6 { } type EnvLike = Record +type ProxyReachabilityStatus = 'checking' | 'reachable' | 'unreachable' + +const PROXY_REACHABILITY_TIMEOUT_MS = 500 + +let proxyReachability: + | { + url: string + status: ProxyReachabilityStatus + } + | undefined +let proxyReachabilityPromise: Promise | undefined /** * Get the active proxy URL if one is configured @@ -62,7 +74,39 @@ type EnvLike = Record * @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 + const proxyUrl = + env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY + + if (!proxyUrl || env !== process.env) { + return proxyUrl + } + + if (!isValidProxyUrl(proxyUrl)) { + clearMatchingProxyEnv(proxyUrl) + return undefined + } + + ensureProxyReachabilityCheck(proxyUrl) + + if (proxyReachability?.url !== proxyUrl) { + return proxyUrl + } + + if (proxyReachability.status === 'unreachable') { + return undefined + } + + // Most stale proxy env issues come from local desktop proxies. Avoid routing + // startup traffic into a loopback proxy until a quick TCP probe confirms it + // is actually listening. + if ( + proxyReachability.status === 'checking' && + isLoopbackProxyUrl(proxyUrl) + ) { + return undefined + } + + return proxyUrl } /** @@ -327,6 +371,7 @@ let proxyInterceptorId: number | undefined export function configureGlobalAgents(): void { const proxyUrl = getProxyUrl() const mtlsAgent = getMTLSAgent() + const isBunRuntime = typeof Bun !== 'undefined' // Eject previous interceptor to avoid stacking on repeated calls if (proxyInterceptorId !== undefined) { @@ -367,18 +412,24 @@ export function configureGlobalAgents(): void { 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), - ) + // Bun fetch uses the per-request `proxy` option from getProxyFetchOptions(). + // Avoid requiring undici here: compiled Bun binaries do not need it, and + // proxy env vars should not be the thing that makes startup depend on a + // dynamic Node-only require. + if (!isBunRuntime) { + // 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) { + if (!isBunRuntime && mtlsOptions.dispatcher) { // eslint-disable-next-line @typescript-eslint/no-require-imports ;(require('undici') as typeof undici).setGlobalDispatcher( mtlsOptions.dispatcher, @@ -424,3 +475,133 @@ export function clearProxyCache(): void { getProxyAgent.cache.clear?.() logForDebugging('Cleared proxy agent cache') } + +function ensureProxyReachabilityCheck(proxyUrl: string): void { + if ( + proxyReachability?.url === proxyUrl && + proxyReachability.status !== 'unreachable' + ) { + return + } + + proxyReachability = { + url: proxyUrl, + status: 'checking', + } + + const promise = probeProxyReachability(proxyUrl).catch(error => { + logForDebugging(`Proxy: reachability probe failed: ${error}`, { + level: 'warn', + }) + return false + }) + proxyReachabilityPromise = promise + + void promise.then(reachable => { + if (proxyReachability?.url !== proxyUrl) { + return + } + + if (reachable) { + proxyReachability = { url: proxyUrl, status: 'reachable' } + configureGlobalAgents() + return + } + + proxyReachability = { url: proxyUrl, status: 'unreachable' } + clearMatchingProxyEnv(proxyUrl) + clearProxyCache() + configureGlobalAgents() + }) +} + +function probeProxyReachability(proxyUrl: string): Promise { + let url: URL + try { + url = new URL(proxyUrl) + } catch { + return Promise.resolve(false) + } + + const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80)) + const host = getProxyHost(url) + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { + return Promise.resolve(false) + } + + return new Promise(resolve => { + const socket = connect({ host, port }) + let done = false + + const finish = (reachable: boolean) => { + if (done) return + done = true + socket.destroy() + resolve(reachable) + } + + socket.setTimeout(PROXY_REACHABILITY_TIMEOUT_MS) + socket.once('connect', () => finish(true)) + socket.once('timeout', () => finish(false)) + socket.once('error', () => finish(false)) + }) +} + +function isLoopbackProxyUrl(proxyUrl: string): boolean { + try { + const hostname = new URL(proxyUrl).hostname.toLowerCase() + return ( + hostname === 'localhost' || + hostname === '::1' || + hostname === '[::1]' || + hostname.startsWith('127.') + ) + } catch { + return false + } +} + +function getProxyHost(url: URL): string { + return url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname +} + +function isValidProxyUrl(proxyUrl: string): boolean { + try { + const url = new URL(proxyUrl) + return ( + !!url.hostname && + (url.protocol === 'http:' || url.protocol === 'https:') + ) + } catch { + return false + } +} + +function clearMatchingProxyEnv(proxyUrl: string): void { + for (const key of [ + 'https_proxy', + 'HTTPS_PROXY', + 'http_proxy', + 'HTTP_PROXY', + ]) { + if (process.env[key] === proxyUrl) { + delete process.env[key] + } + } + logForDebugging(`Proxy: disabled unreachable proxy ${proxyUrl}`, { + level: 'warn', + }) +} + +export function _resetProxyReachabilityForTesting(): void { + proxyReachability = undefined + proxyReachabilityPromise = undefined +} + +export async function _waitForProxyReachabilityForTesting(): Promise< + boolean | undefined +> { + return proxyReachabilityPromise +} From c473fef8d842af4ec56b609b4010916e864b70bc Mon Sep 17 00:00:00 2001 From: IronRookieCoder Date: Tue, 12 May 2026 15:58:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=E5=B0=86=20undici=20=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=E8=BF=90=E8=A1=8C=E6=97=B6=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit undici 仍被代理和 mTLS 逻辑按需 require,因此需要保留为运行时依赖。 --- bun.lock | 2 +- package.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 63a28a6b2..475d39282 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@agentclientprotocol/sdk": "^0.19.0", "@claude-code-best/mcp-chrome-bridge": "^2.0.8", "gray-matter": "^4.0.3", + "undici": "^7.24.6", "ws": "^8.20.0", }, "devDependencies": { @@ -133,7 +134,6 @@ "turndown": "^7.2.2", "type-fest": "^5.5.0", "typescript": "^6.0.2", - "undici": "^7.24.6", "url-handler-napi": "workspace:*", "usehooks-ts": "^3.1.1", "vite": "^8.0.8", diff --git a/package.json b/package.json index e5d1ec370..9d5f00fbd 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,6 @@ "turndown": "^7.2.2", "type-fest": "^5.5.0", "typescript": "^6.0.2", - "undici": "^7.24.6", "url-handler-napi": "workspace:*", "usehooks-ts": "^3.1.1", "vite": "^8.0.8", @@ -237,4 +236,4 @@ "biome format --write --no-errors-on-unmatched" ] } -} \ No newline at end of file +}