mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
6e9c13f032
commit
bec556679f
@ -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', () => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
52
src/utils/proxy.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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') {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user