fix: bypass local provider proxy for loopback targets

Preserve loopback NO_PROXY entries when Electron or manual network settings inject proxy environment variables, and make the Anthropic client skip Bun proxy fetch options for local desktop provider proxy base URLs.

Tested: bun test src/utils/proxy.test.ts src/services/api/client.test.ts src/server/__tests__/network-settings.test.ts src/server/__tests__/conversation-service.test.ts

Tested: cd desktop && bun run test -- electron/services/sidecarManager.test.ts --run

Tested: bun run check:server

Tested: bun run check:native

Not-tested: Windows dev-sidecar real-machine retest for #896; current Mac can only prove loopback proxy bypass behavior and native packaging.

Confidence: medium

Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-23 15:55:03 +08:00
parent 6e9c13f032
commit bec556679f
10 changed files with 206 additions and 12 deletions

View File

@ -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', () => {

View File

@ -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.

View File

@ -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 () => {

View File

@ -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',
})
})

View File

@ -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()

View File

@ -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<string, string> {
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<string, string> {
const env: Record<string, string> = {
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

View File

@ -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
}
})
})

View File

@ -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() }),
}

52
src/utils/proxy.test.ts Normal file
View File

@ -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')
})
})

View File

@ -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') {