diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index 6ed5c199..186cbc5e 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -134,15 +134,19 @@ describe('Electron sidecar manager', () => { expect(env.http_proxy).toBe('http://127.0.0.1:7897') expect(env.https_proxy).toBe('http://127.0.0.1:7897') expect(env.NO_PROXY).toContain('127.0.0.1') + expect(env.no_proxy).toContain('localhost') }) - it('does not override explicit sidecar proxy environment', () => { + it('does not override explicit sidecar proxy environment and still preserves loopback bypasses', () => { const env = mergeProxyEnv( - { HTTPS_PROXY: 'http://manual.example:8080' }, + { HTTPS_PROXY: 'http://manual.example:8080', NO_PROXY: '.corp.local' }, 'http://system.example:8080', ) - expect(env).toEqual({ HTTPS_PROXY: 'http://manual.example:8080' }) + expect(env.HTTPS_PROXY).toBe('http://manual.example:8080') + expect(env.HTTP_PROXY).toBeUndefined() + expect(env.NO_PROXY).toBe('.corp.local,localhost,127.0.0.1,::1') + expect(env.no_proxy).toBe('.corp.local,localhost,127.0.0.1,::1') }) it('keeps startup logs bounded', () => { diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index f3ae6295..02797ca9 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -34,6 +34,7 @@ const PROXY_ENV_KEYS = [ 'http_proxy', 'https_proxy', ] as const +const LOOPBACK_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1'] as const export function resolveHostTriple(platform = process.platform, arch = process.arch): string { if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin' @@ -248,7 +249,12 @@ export function mergeProxyEnv( proxyUrl: string | undefined, ): NodeJS.ProcessEnv { if (!proxyUrl) return baseEnv - if (PROXY_ENV_KEYS.some(key => baseEnv[key])) return baseEnv + if (PROXY_ENV_KEYS.some(key => baseEnv[key])) { + const noProxy = mergeLoopbackNoProxy(baseEnv.no_proxy || baseEnv.NO_PROXY) + return { ...baseEnv, NO_PROXY: noProxy, no_proxy: noProxy } + } + + const noProxy = mergeLoopbackNoProxy(baseEnv.no_proxy || baseEnv.NO_PROXY) return { ...baseEnv, @@ -256,10 +262,25 @@ export function mergeProxyEnv( HTTPS_PROXY: proxyUrl, http_proxy: proxyUrl, https_proxy: proxyUrl, - NO_PROXY: baseEnv.NO_PROXY || baseEnv.no_proxy || 'localhost,127.0.0.1,::1', + NO_PROXY: noProxy, + no_proxy: noProxy, } } +function mergeLoopbackNoProxy(existing: string | undefined): string { + const entries = (existing ?? '') + .split(/[,\s]+/) + .map(entry => entry.trim()) + .filter(Boolean) + const lowerEntries = new Set(entries.map(entry => entry.toLowerCase())) + + for (const entry of LOOPBACK_NO_PROXY_ENTRIES) { + if (!lowerEntries.has(entry.toLowerCase())) entries.push(entry) + } + + return entries.join(',') +} + // The agent's PowerShellTool reads this env var to honor the user's chosen shell // (mirrors src/utils/shell/powershellDetection.ts). Without it the agent would // re-autodetect PowerShell instead of using the shell the user picked in the UI. diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 60bc2652..177e3b01 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -314,6 +314,8 @@ describe('ConversationService', () => { expect(env.API_TIMEOUT_MS).toBe('180000') expect(env.HTTP_PROXY).toBe('http://127.0.0.1:7890') expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:7890') + expect(env.NO_PROXY).toContain('127.0.0.1') + expect(env.no_proxy).toContain('localhost') }) test('buildChildEnv ties the first-token watchdog to the user request timeout so slow prefill is not killed early (#826)', async () => { diff --git a/src/server/__tests__/network-settings.test.ts b/src/server/__tests__/network-settings.test.ts index e8618fab..a66d9588 100644 --- a/src/server/__tests__/network-settings.test.ts +++ b/src/server/__tests__/network-settings.test.ts @@ -96,6 +96,24 @@ describe('network settings', () => { HTTPS_PROXY: 'http://127.0.0.1:7890', http_proxy: 'http://127.0.0.1:7890', https_proxy: 'http://127.0.0.1:7890', + NO_PROXY: 'localhost,127.0.0.1,::1', + no_proxy: 'localhost,127.0.0.1,::1', + }) + }) + + it('preserves custom no_proxy entries while adding loopback bypasses for manual proxies', () => { + const settings = normalizeNetworkSettings({ + network: { + proxy: { + mode: 'manual', + url: 'http://proxy.example:8080', + }, + }, + }) + + expect(buildNetworkEnvironment(settings, { no_proxy: '.corp.local,10.0.0.0/8' })).toMatchObject({ + NO_PROXY: '.corp.local,10.0.0.0/8,localhost,127.0.0.1,::1', + no_proxy: '.corp.local,10.0.0.0/8,localhost,127.0.0.1,::1', }) }) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index d2611769..ce98f262 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -1095,7 +1095,7 @@ export class ConversationService { const explicitProviderEnv = explicitProvider ? await this.providerService.getProviderRuntimeEnv(explicitProvider.id) : null - const networkEnv = buildNetworkEnvironment(await loadNetworkSettings()) + const networkEnv = buildNetworkEnvironment(await loadNetworkSettings(), cleanEnv) const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled if (explicitProviderEnv && options?.model?.trim()) { explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() diff --git a/src/server/services/networkSettings.ts b/src/server/services/networkSettings.ts index 845e39e0..036d924b 100644 --- a/src/server/services/networkSettings.ts +++ b/src/server/services/networkSettings.ts @@ -34,6 +34,7 @@ const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { url: '', }, } +const LOOPBACK_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1'] as const function isNetworkProxyMode(value: unknown): value is NetworkProxyMode { return value === 'system' || value === 'manual' @@ -85,17 +86,37 @@ export function getManualNetworkProxyUrl(settings: NetworkSettings): string | un return url || undefined } -export function buildNetworkEnvironment(settings: NetworkSettings): Record { +function mergeLoopbackNoProxy(existing: string | undefined): string { + const entries = (existing ?? '') + .split(/[,\s]+/) + .map(entry => entry.trim()) + .filter(Boolean) + const lowerEntries = new Set(entries.map(entry => entry.toLowerCase())) + + for (const entry of LOOPBACK_NO_PROXY_ENTRIES) { + if (!lowerEntries.has(entry.toLowerCase())) entries.push(entry) + } + + return entries.join(',') +} + +export function buildNetworkEnvironment( + settings: NetworkSettings, + baseEnv: NodeJS.ProcessEnv = process.env, +): Record { const env: Record = { API_TIMEOUT_MS: String(settings.aiRequestTimeoutMs), } const proxyUrl = getManualNetworkProxyUrl(settings) if (proxyUrl) { + const noProxy = mergeLoopbackNoProxy(baseEnv.no_proxy || baseEnv.NO_PROXY) env.HTTP_PROXY = proxyUrl env.HTTPS_PROXY = proxyUrl env.http_proxy = proxyUrl env.https_proxy = proxyUrl + env.NO_PROXY = noProxy + env.no_proxy = noProxy } return env diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 8f5a7dde..55aa2464 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -114,4 +114,68 @@ describe('getAnthropicClient', () => { else process.env.CLAUDE_CODE_SIMPLE = originalSimple } }) + + test('bypasses system proxy for local desktop provider proxy base URLs', async () => { + const { getAnthropicClient } = await import('./client.js') + const originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN + const originalApiKey = process.env.ANTHROPIC_API_KEY + const originalBaseUrl = process.env.ANTHROPIC_BASE_URL + const originalHttpProxy = process.env.HTTP_PROXY + const originalHttpsProxy = process.env.HTTPS_PROXY + const originalNoProxy = process.env.NO_PROXY + const originalLowerHttpProxy = process.env.http_proxy + const originalLowerHttpsProxy = process.env.https_proxy + const originalLowerNoProxy = process.env.no_proxy + const originalSimple = process.env.CLAUDE_CODE_SIMPLE + + process.env.ANTHROPIC_AUTH_TOKEN = 'provider-bearer-token' + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:3456/proxy/providers/provider-1' + process.env.HTTP_PROXY = 'http://127.0.0.1:1181' + process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' + process.env.NO_PROXY = 'localhost,127.0.0.1,::1' + process.env.CLAUDE_CODE_SIMPLE = '1' + delete process.env.ANTHROPIC_API_KEY + delete process.env.http_proxy + delete process.env.https_proxy + delete process.env.no_proxy + + try { + const client = await getAnthropicClient({ + maxRetries: 0, + model: 'deepseek-v4-pro', + }) + + expect(client._options.fetchOptions?.proxy).toBeUndefined() + } finally { + if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN + else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken + + if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY + else process.env.ANTHROPIC_API_KEY = originalApiKey + + if (originalBaseUrl === undefined) delete process.env.ANTHROPIC_BASE_URL + else process.env.ANTHROPIC_BASE_URL = originalBaseUrl + + if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY + else process.env.HTTP_PROXY = originalHttpProxy + + if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY + else process.env.HTTPS_PROXY = originalHttpsProxy + + if (originalNoProxy === undefined) delete process.env.NO_PROXY + else process.env.NO_PROXY = originalNoProxy + + if (originalLowerHttpProxy === undefined) delete process.env.http_proxy + else process.env.http_proxy = originalLowerHttpProxy + + if (originalLowerHttpsProxy === undefined) delete process.env.https_proxy + else process.env.https_proxy = originalLowerHttpsProxy + + if (originalLowerNoProxy === undefined) delete process.env.no_proxy + else process.env.no_proxy = originalLowerNoProxy + + if (originalSimple === undefined) delete process.env.CLAUDE_CODE_SIMPLE + else process.env.CLAUDE_CODE_SIMPLE = originalSimple + } + }) }) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index f3959d47..17c32f1a 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -207,6 +207,11 @@ export async function getAnthropicClient({ const resolvedFetch = usingOpenAICodex ? buildOpenAICodexFetch(fetchOverride, source) : buildFetch(fetchOverride, source) + const stagingOAuthBaseUrl = process.env.USER_TYPE === 'ant' && + isEnvTruthy(process.env.USE_STAGING_OAUTH) + ? getOauthConfig().BASE_API_URL + : undefined + const baseURL = stagingOAuthBaseUrl || process.env.ANTHROPIC_BASE_URL const ARGS = { defaultHeaders, @@ -215,6 +220,7 @@ export async function getAnthropicClient({ dangerouslyAllowBrowser: true, fetchOptions: getProxyFetchOptions({ forAnthropicAPI: true, + targetUrl: baseURL, }) as ClientOptions['fetchOptions'], ...(resolvedFetch && { fetch: resolvedFetch, @@ -378,10 +384,7 @@ export async function getAnthropicClient({ ? getClaudeAIOAuthTokens()?.accessToken : undefined, // Set baseURL from OAuth config when using staging OAuth - ...(process.env.USER_TYPE === 'ant' && - isEnvTruthy(process.env.USE_STAGING_OAUTH) - ? { baseURL: getOauthConfig().BASE_API_URL } - : {}), + ...(stagingOAuthBaseUrl ? { baseURL: stagingOAuthBaseUrl } : {}), ...ARGS, ...(isDebugToStdErr() && { logger: createStderrLogger() }), } diff --git a/src/utils/proxy.test.ts b/src/utils/proxy.test.ts new file mode 100644 index 00000000..ff127575 --- /dev/null +++ b/src/utils/proxy.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { getProxyFetchOptions, shouldBypassProxy } from './proxy.js' + +const originalEnv = { + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + http_proxy: process.env.http_proxy, + https_proxy: process.env.https_proxy, + NO_PROXY: process.env.NO_PROXY, + no_proxy: process.env.no_proxy, +} + +function restoreEnv() { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +} + +describe('proxy environment handling', () => { + afterEach(restoreEnv) + + test('bypasses proxy fetch options for loopback provider proxy targets', () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:1181' + process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' + process.env.NO_PROXY = 'localhost,127.0.0.1,::1' + delete process.env.http_proxy + delete process.env.https_proxy + delete process.env.no_proxy + + expect(shouldBypassProxy('http://127.0.0.1:3456/proxy/providers/p1/v1/messages')).toBe(true) + expect(getProxyFetchOptions({ + forAnthropicAPI: true, + targetUrl: 'http://127.0.0.1:3456/proxy/providers/p1', + }).proxy).toBeUndefined() + }) + + test('keeps proxy fetch options for external provider targets', () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:1181' + process.env.HTTPS_PROXY = 'http://127.0.0.1:1181' + process.env.NO_PROXY = 'localhost,127.0.0.1,::1' + delete process.env.http_proxy + delete process.env.https_proxy + delete process.env.no_proxy + + expect(shouldBypassProxy('https://api.example.com/v1/messages')).toBe(false) + expect(getProxyFetchOptions({ + forAnthropicAPI: true, + targetUrl: 'https://api.example.com', + }).proxy).toBe('http://127.0.0.1:1181') + }) +}) diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 7675ec81..aaa11329 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -128,6 +128,11 @@ export function shouldBypassProxy( } } +function shouldBypassProxyForTarget(targetUrl: string | URL | undefined | null): boolean { + if (!targetUrl) return false + return shouldBypassProxy(String(targetUrl)) +} + /** * Create an HttpsProxyAgent with optional mTLS configuration * Skips local DNS resolution to let the proxy handle it @@ -285,7 +290,7 @@ export function getWebSocketProxyUrl(url: string): string | undefined { * requests get misrouted to api.anthropic.com. Only the Anthropic SDK client * should pass `true` here. */ -export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUrl?: string | null }): { +export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUrl?: string | null; targetUrl?: string | URL | null }): { tls?: TLSConfig dispatcher?: undici.Dispatcher proxy?: string @@ -308,6 +313,10 @@ export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUr ? opts.proxyUrl || undefined : getProxyUrl() + if (proxyUrl && shouldBypassProxyForTarget(opts?.targetUrl)) { + return { ...base, ...getTLSFetchOptions() } + } + // If we have a proxy, use the proxy agent (which includes mTLS config) if (proxyUrl) { if (typeof Bun !== 'undefined') {