fix(proxy): route AI sessions through dynamic system proxy #953

This commit is contained in:
程序员阿江(Relakkes) 2026-07-16 17:33:33 +08:00
parent 53a15098c0
commit c06f958e7c
40 changed files with 2404 additions and 180 deletions

View File

@ -5,7 +5,9 @@ import path from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidecarChild, SidecarPlan } from './sidecarManager'
import { SYSTEM_PROXY_ERROR_ENV } from './sidecarManager'
import { ElectronServerRuntime } from './serverRuntime'
import type { SystemProxyBridgeLike } from './systemProxyBridge'
const sidecarMocks = {
nextPort: 49321,
@ -38,12 +40,19 @@ class FakeSidecarChild extends EventEmitter {
readonly kill = vi.fn()
}
function createRuntime(options: { appRoot?: string, diagnosticsFile?: string } = {}) {
function createRuntime(options: {
appRoot?: string
diagnosticsFile?: string
env?: NodeJS.ProcessEnv
resolveSystemProxy?: (url: string) => Promise<string>
proxyBridge?: SystemProxyBridgeLike
} = {}) {
return new ElectronServerRuntime({
desktopRoot: '/isolated/desktop',
appRoot: options.appRoot,
diagnosticsFile: options.diagnosticsFile,
env: { CLAUDE_CONFIG_DIR: isolatedConfigDir },
env: { CLAUDE_CONFIG_DIR: isolatedConfigDir, ...options.env },
resolveSystemProxy: options.resolveSystemProxy,
deps: {
appendHostDiagnostic: sidecarMocks.appendHostDiagnostic,
preferredServerPorts: () => [],
@ -51,6 +60,9 @@ function createRuntime(options: { appRoot?: string, diagnosticsFile?: string } =
spawnSidecar: sidecarMocks.spawnSidecar,
waitForServer: async () => await sidecarMocks.waitForServerImpl(),
writeLastServerPort: () => undefined,
...(options.proxyBridge
? { createSystemProxyBridge: () => options.proxyBridge! }
: {}),
},
})
}
@ -140,6 +152,99 @@ describe('ElectronServerRuntime', () => {
}
})
it('gives the server only the dynamic bridge URL while adapters explicitly inherit it', async () => {
const bridge = {
start: vi.fn(async () => 'http://127.0.0.1:49123'),
stop: vi.fn(async () => undefined),
}
const runtime = createRuntime({
env: {
HTTP_PROXY: 'http://stale.example:8080',
HTTPS_PROXY: 'http://stale.example:8080',
ALL_PROXY: 'socks5://stale.example:1080',
all_proxy: 'socks5://stale.example:1080',
},
resolveSystemProxy: async () => 'DIRECT',
proxyBridge: bridge,
})
await runtime.startServer()
const serverEnv = sidecarMocks.serverPlans[0]!.env
expect(serverEnv.CC_HAHA_SYSTEM_PROXY_URL).toBe('http://127.0.0.1:49123')
expect(serverEnv.HTTP_PROXY).toBeUndefined()
expect(serverEnv.HTTPS_PROXY).toBeUndefined()
expect(serverEnv.ALL_PROXY).toBeUndefined()
expect(serverEnv.all_proxy).toBeUndefined()
const adapterPlans = sidecarMocks.spawnSidecar.mock.calls
.map(([plan]) => plan)
.filter(plan => plan.args[0] === 'adapters')
expect(adapterPlans).toHaveLength(5)
for (const plan of adapterPlans) {
expect(plan.env.HTTP_PROXY).toBe('http://127.0.0.1:49123')
expect(plan.env.HTTPS_PROXY).toBe('http://127.0.0.1:49123')
expect(plan.env.ALL_PROXY).toBe('http://127.0.0.1:49123')
expect(plan.env.all_proxy).toBe('http://127.0.0.1:49123')
}
runtime.stopAll()
expect(bridge.stop).toHaveBeenCalledTimes(1)
})
it('does not spawn a server when stopAll races with proxy bridge startup', async () => {
let releaseBridge!: (url: string) => void
const bridge = {
start: vi.fn(() => new Promise<string>(resolve => { releaseBridge = resolve })),
stop: vi.fn(async () => undefined),
}
const runtime = createRuntime({
resolveSystemProxy: async () => 'DIRECT',
proxyBridge: bridge,
})
const starting = runtime.startServer()
for (let attempt = 0; attempt < 10 && bridge.start.mock.calls.length === 0; attempt++) {
await Promise.resolve()
}
expect(bridge.start).toHaveBeenCalledTimes(1)
runtime.stopAll()
releaseBridge('http://127.0.0.1:49123')
await expect(starting).rejects.toThrow('server startup stopped')
expect(sidecarMocks.spawnSidecar).not.toHaveBeenCalled()
expect(bridge.stop).toHaveBeenCalledTimes(1)
})
it('passes a sanitized bridge startup failure to the server without silently using direct mode', async () => {
const bridge = {
start: vi.fn(async () => {
throw new Error('failed via https://user:password@proxy.example/path with sk-secret12345678')
}),
stop: vi.fn(async () => undefined),
}
const runtime = createRuntime({
env: {
HTTP_PROXY: 'http://stale.example:8080',
HTTPS_PROXY: 'http://stale.example:8080',
ALL_PROXY: 'socks5://stale.example:1080',
},
resolveSystemProxy: async () => 'DIRECT',
proxyBridge: bridge,
})
await runtime.startServer()
const serverEnv = sidecarMocks.serverPlans[0]!.env
expect(serverEnv.HTTP_PROXY).toBeUndefined()
expect(serverEnv.HTTPS_PROXY).toBeUndefined()
expect(serverEnv.ALL_PROXY).toBeUndefined()
expect(serverEnv.CC_HAHA_SYSTEM_PROXY_URL).toBeUndefined()
expect(serverEnv[SYSTEM_PROXY_ERROR_ENV]).toContain('System proxy bridge unavailable: failed via')
expect(serverEnv[SYSTEM_PROXY_ERROR_ENV]).not.toContain('password')
expect(serverEnv[SYSTEM_PROXY_ERROR_ENV]).not.toContain('sk-secret')
expect(sidecarMocks.serverChildren).toHaveLength(1)
})
it('persists a server startup failure through the sanitized host-log boundary', async () => {
sidecarMocks.spawnError = new Error('spawn failed')
const runtime = createRuntime({

View File

@ -2,15 +2,14 @@ import path from 'node:path'
import { randomBytes } from 'node:crypto'
import {
appendHostDiagnostic,
clearProxyEnv,
createAdapterPlan,
createServerPlan,
ELECTRON_DIAGNOSTICS_FILE_ENV,
formatStartupError,
killSidecar,
mergeProxyEnv,
POWERSHELL_PATH_OVERRIDE_ENV,
preferredServerPorts,
proxyUrlFromElectronProxyRules,
pushStartupLog,
reserveServerPort,
sanitizeHostDiagnostic,
@ -19,11 +18,18 @@ import {
SERVER_STARTUP_TIMEOUT_MS,
spawnSidecar,
waitForServer,
withAdapterProxyBridgeEnv,
withSystemProxyBridgeEnv,
withSystemProxyErrorEnv,
windowsPowerShellOverride,
writeLastServerPort,
type SidecarChild,
} from './sidecarManager'
import { readDesktopTerminalConfig, resolveDesktopTerminalShell } from './terminal'
import {
SystemProxyBridge,
type SystemProxyBridgeLike,
} from './systemProxyBridge'
type ServerRuntimeOptions = {
desktopRoot: string
@ -42,6 +48,7 @@ type ServerRuntimeDeps = {
spawnSidecar: typeof spawnSidecar
waitForServer: typeof waitForServer
writeLastServerPort: typeof writeLastServerPort
createSystemProxyBridge: (resolveSystemProxy: (url: string) => Promise<string>) => SystemProxyBridgeLike
}
const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = {
@ -51,6 +58,7 @@ const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = {
spawnSidecar,
waitForServer,
writeLastServerPort,
createSystemProxyBridge: resolveSystemProxy => new SystemProxyBridge(resolveSystemProxy),
}
type ServerStartState = {
@ -100,11 +108,13 @@ export class ElectronServerRuntime {
private readonly resolveSystemProxy?: (url: string) => Promise<string>
private readonly localAccessToken = randomBytes(32).toString('base64url')
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
private systemProxyBridge: SystemProxyBridgeLike | null = null
private server: ActiveServer | null = null
private adapters: SidecarChild[] = []
private startupError: string | null = null
private restartAfterExit = false
private startPromise: Promise<string> | null = null
private lifecycleGeneration = 0
private startingServer: ServerStartState | null = null
private adapterRestartPromise: Promise<void> | null = null
@ -123,7 +133,8 @@ export class ElectronServerRuntime {
if (this.startPromise) return this.startPromise
this.restartAfterExit = false
this.startPromise = this.startServerOnce()
const generation = this.lifecycleGeneration
this.startPromise = this.startServerOnce(generation)
try {
return await this.startPromise
} finally {
@ -165,6 +176,7 @@ export class ElectronServerRuntime {
}
stopAll(sync = false) {
++this.lifecycleGeneration
const starting = this.startingServer
if (starting) {
this.startingServer = null
@ -181,9 +193,10 @@ export class ElectronServerRuntime {
killSidecar(this.server.child, sync)
this.server = null
}
this.stopSystemProxyBridge()
}
private async startServerOnce(): Promise<string> {
private async startServerOnce(generation: number): Promise<string> {
// Prefer the configured fixed port, then the previous run's port, so
// phone bookmarks / QR codes / reverse proxies survive restarts (#767).
const port = await this.deps.reserveServerPort(
@ -194,6 +207,7 @@ export class ElectronServerRuntime {
const logs: string[] = []
let startState: ServerStartState | null = null
const env = this.withLocalAccessToken(await this.resolveSidecarBaseEnv())
this.assertCurrentGeneration(generation)
const plan = createServerPlan({
desktopRoot: this.desktopRoot,
appRoot: this.appRoot,
@ -250,12 +264,20 @@ export class ElectronServerRuntime {
}
}
private assertCurrentGeneration(generation: number): void {
if (generation !== this.lifecycleGeneration) throw new Error('server startup stopped')
}
private async startAdaptersSidecars(
serverUrl: string,
startState?: ServerStartState,
activeServer?: ActiveServer,
): Promise<void> {
const env = this.withLocalAccessToken(await this.resolveSidecarBaseEnv())
const baseEnv = this.withLocalAccessToken(await this.resolveSidecarBaseEnv())
const bridgeUrl = baseEnv.CC_HAHA_SYSTEM_PROXY_URL
const env = bridgeUrl
? withAdapterProxyBridgeEnv(baseEnv, bridgeUrl)
: baseEnv
const isCurrentGeneration = () => {
if (startState?.failure) return false
if (activeServer && this.server !== activeServer) return false
@ -408,20 +430,35 @@ export class ElectronServerRuntime {
}
private async resolveSidecarBaseEnvOnce(): Promise<NodeJS.ProcessEnv> {
if (!this.resolveSystemProxy) return this.applyPowerShellOverride(this.baseEnv)
const baseEnv = clearProxyEnv(this.baseEnv)
if (!this.resolveSystemProxy) return this.applyPowerShellOverride(baseEnv)
const bridge = this.deps.createSystemProxyBridge(this.resolveSystemProxy)
this.systemProxyBridge = bridge
try {
const rules = await this.resolveSystemProxy('https://auth.openai.com/')
return this.applyPowerShellOverride(mergeProxyEnv(
this.baseEnv,
proxyUrlFromElectronProxyRules(rules),
))
const bridgeUrl = await bridge.start()
if (this.systemProxyBridge !== bridge) {
throw new Error('system proxy bridge startup was stopped')
}
return this.applyPowerShellOverride(withSystemProxyBridgeEnv(baseEnv, bridgeUrl))
} catch (error) {
console.error('[desktop] failed to resolve system proxy for sidecars', error)
return this.applyPowerShellOverride(this.baseEnv)
if (this.systemProxyBridge === bridge) {
this.systemProxyBridge = null
await bridge.stop().catch(() => {})
}
const message = error instanceof Error ? error.message : String(error)
console.error(`[desktop] failed to start system proxy bridge for sidecars: ${sanitizeHostDiagnostic(message)}`)
return this.applyPowerShellOverride(withSystemProxyErrorEnv(baseEnv, error))
}
}
private stopSystemProxyBridge(): void {
const bridge = this.systemProxyBridge
this.systemProxyBridge = null
this.sidecarEnvPromise = null
if (bridge) void bridge.stop()
}
// On Windows, forward the user's chosen PowerShell to the agent sidecar so its
// PowerShellTool honors the same shell as the UI terminal (regression from the
// Tauri build, where this lived in src-tauri/src/lib.rs). Best-effort: never

View File

@ -7,6 +7,7 @@ import { homedir, tmpdir } from 'node:os'
import {
appendHostDiagnostic,
buildSidecarEnv,
clearProxyEnv,
createAdapterPlan,
createServerPlan,
electronHostDiagnosticsFile,
@ -14,10 +15,8 @@ import {
HOST_DIAGNOSTICS_BYTE_LIMIT,
HOST_DIAGNOSTICS_LINE_LIMIT,
killSidecar,
mergeProxyEnv,
parseH5FixedPort,
preferredServerPorts,
proxyUrlFromElectronProxyRules,
pushStartupLog,
readH5FixedPort,
readLastServerPort,
@ -27,8 +26,13 @@ import {
resolveHostTriple,
RIPGREP_PATH_ENV,
SERVER_STATE_FILE,
SYSTEM_PROXY_BRIDGE_ENV,
SYSTEM_PROXY_ERROR_ENV,
spawnSidecar,
waitForServer,
withAdapterProxyBridgeEnv,
withSystemProxyBridgeEnv,
withSystemProxyErrorEnv,
windowsPowerShellOverride,
writeLastServerPort,
type SidecarChild,
@ -199,31 +203,63 @@ describe('Electron sidecar manager', () => {
}
})
it('converts Electron system proxy rules into sidecar proxy env', () => {
expect(proxyUrlFromElectronProxyRules('DIRECT')).toBeUndefined()
expect(proxyUrlFromElectronProxyRules('SOCKS5 127.0.0.1:7891; DIRECT')).toBeUndefined()
expect(proxyUrlFromElectronProxyRules('PROXY 127.0.0.1:7897; DIRECT')).toBe('http://127.0.0.1:7897')
expect(proxyUrlFromElectronProxyRules('HTTPS proxy.example:8443; DIRECT')).toBe('https://proxy.example:8443')
it('isolates the server from inherited proxy env and exposes only the dynamic bridge URL', () => {
const baseEnv = {
HTTP_PROXY: 'http://stale.example:8080',
HTTPS_PROXY: 'http://stale.example:8080',
http_proxy: 'http://stale.example:8080',
https_proxy: 'http://stale.example:8080',
ALL_PROXY: 'socks5://stale.example:1080',
all_proxy: 'socks5://stale.example:1080',
NO_PROXY: '.corp.local',
}
const bridgeUrl = 'http://127.0.0.1:49123'
const serverEnv = withSystemProxyBridgeEnv(baseEnv, bridgeUrl)
const env = mergeProxyEnv({}, 'http://127.0.0.1:7897')
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:7897')
expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:7897')
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')
expect(serverEnv[SYSTEM_PROXY_BRIDGE_ENV]).toBe(bridgeUrl)
expect(serverEnv.HTTP_PROXY).toBeUndefined()
expect(serverEnv.HTTPS_PROXY).toBeUndefined()
expect(serverEnv.http_proxy).toBeUndefined()
expect(serverEnv.https_proxy).toBeUndefined()
expect(serverEnv.ALL_PROXY).toBeUndefined()
expect(serverEnv.all_proxy).toBeUndefined()
expect(serverEnv.NO_PROXY).toBe('.corp.local,localhost,127.0.0.1,::1')
expect(clearProxyEnv(baseEnv).HTTP_PROXY).toBeUndefined()
})
it('does not override explicit sidecar proxy environment and still preserves loopback bypasses', () => {
const env = mergeProxyEnv(
{ HTTPS_PROXY: 'http://manual.example:8080', NO_PROXY: '.corp.local' },
'http://system.example:8080',
)
it('exposes a sanitized system proxy failure without leaving a direct-fallback proxy env', () => {
const env = withSystemProxyErrorEnv({
HTTP_PROXY: 'http://stale.example:8080',
HTTPS_PROXY: 'http://stale.example:8080',
ALL_PROXY: 'socks5://stale.example:1080',
[SYSTEM_PROXY_BRIDGE_ENV]: 'http://127.0.0.1:49123',
}, new Error('bridge failed for https://user:password@proxy.example/path with sk-secret12345678'))
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')
expect(env.HTTPS_PROXY).toBeUndefined()
expect(env.ALL_PROXY).toBeUndefined()
expect(env[SYSTEM_PROXY_BRIDGE_ENV]).toBeUndefined()
expect(env[SYSTEM_PROXY_ERROR_ENV]).toContain('System proxy bridge unavailable: bridge failed')
expect(env[SYSTEM_PROXY_ERROR_ENV]).toContain('https://[REDACTED]@proxy.example/path')
expect(env[SYSTEM_PROXY_ERROR_ENV]).not.toContain('password')
expect(env[SYSTEM_PROXY_ERROR_ENV]).not.toContain('sk-secret')
})
it('routes adapter sidecars explicitly through the dynamic bridge', () => {
const bridgeUrl = 'http://127.0.0.1:49123'
const env = withAdapterProxyBridgeEnv({
HTTPS_PROXY: 'http://stale.example:8080',
ALL_PROXY: 'socks5://stale.example:1080',
[SYSTEM_PROXY_BRIDGE_ENV]: bridgeUrl,
}, bridgeUrl)
expect(env.HTTP_PROXY).toBe(bridgeUrl)
expect(env.HTTPS_PROXY).toBe(bridgeUrl)
expect(env.http_proxy).toBe(bridgeUrl)
expect(env.https_proxy).toBe(bridgeUrl)
expect(env.ALL_PROXY).toBe(bridgeUrl)
expect(env.all_proxy).toBe(bridgeUrl)
expect(env.NO_PROXY).toContain('127.0.0.1')
})
it('keeps startup logs bounded', () => {

View File

@ -52,7 +52,11 @@ const PROXY_ENV_KEYS = [
'HTTPS_PROXY',
'http_proxy',
'https_proxy',
'ALL_PROXY',
'all_proxy',
] as const
export const SYSTEM_PROXY_BRIDGE_ENV = 'CC_HAHA_SYSTEM_PROXY_URL'
export const SYSTEM_PROXY_ERROR_ENV = 'CC_HAHA_SYSTEM_PROXY_ERROR'
const LOOPBACK_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1'] as const
export function resolveHostTriple(platform = process.platform, arch = process.arch): string {
@ -403,46 +407,51 @@ export function formatStartupError(message: string, logs: string[]): string {
return `${message}\n\nRecent server logs:\n${logText}`
}
export function proxyUrlFromElectronProxyRules(rules: string | undefined): string | undefined {
if (!rules) return undefined
for (const rawRule of rules.split(';')) {
const rule = rawRule.trim()
if (!rule || /^DIRECT$/i.test(rule)) continue
const match = rule.match(/^(PROXY|HTTPS)\s+(.+)$/i)
if (!match) continue
const scheme = match[1]!.toUpperCase() === 'HTTPS' ? 'https' : 'http'
const hostPort = match[2]!.trim()
if (!hostPort) continue
return `${scheme}://${hostPort}`
}
return undefined
export function clearProxyEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv }
for (const key of PROXY_ENV_KEYS) delete env[key]
delete env[SYSTEM_PROXY_BRIDGE_ENV]
delete env[SYSTEM_PROXY_ERROR_ENV]
const noProxy = mergeLoopbackNoProxy(env.no_proxy || env.NO_PROXY)
return { ...env, NO_PROXY: noProxy, no_proxy: noProxy }
}
export function mergeProxyEnv(
export function withSystemProxyBridgeEnv(
baseEnv: NodeJS.ProcessEnv,
proxyUrl: string | undefined,
bridgeUrl: string,
): NodeJS.ProcessEnv {
if (!proxyUrl) 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,
HTTP_PROXY: proxyUrl,
HTTPS_PROXY: proxyUrl,
http_proxy: proxyUrl,
https_proxy: proxyUrl,
NO_PROXY: noProxy,
no_proxy: noProxy,
...clearProxyEnv(baseEnv),
[SYSTEM_PROXY_BRIDGE_ENV]: bridgeUrl,
}
}
export function withSystemProxyErrorEnv(
baseEnv: NodeJS.ProcessEnv,
error: unknown,
): NodeJS.ProcessEnv {
const message = error instanceof Error ? error.message : String(error)
const sanitized = sanitizeHostDiagnostic(message).replace(/\s+/g, ' ').trim()
|| 'unknown bridge startup error'
return {
...clearProxyEnv(baseEnv),
[SYSTEM_PROXY_ERROR_ENV]: `System proxy bridge unavailable: ${sanitized}`,
}
}
export function withAdapterProxyBridgeEnv(
baseEnv: NodeJS.ProcessEnv,
bridgeUrl: string,
): NodeJS.ProcessEnv {
const env = clearProxyEnv(baseEnv)
return {
...env,
HTTP_PROXY: bridgeUrl,
HTTPS_PROXY: bridgeUrl,
http_proxy: bridgeUrl,
https_proxy: bridgeUrl,
ALL_PROXY: bridgeUrl,
all_proxy: bridgeUrl,
}
}

View File

@ -0,0 +1,379 @@
import http from 'node:http'
import net from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
parseSystemProxyRules,
sanitizeProxyRequestHeaders,
SystemProxyBridge,
} from './systemProxyBridge'
const servers: Array<http.Server | net.Server> = []
const bridges: SystemProxyBridge[] = []
const sockets = new Set<net.Socket>()
afterEach(async () => {
await Promise.all(bridges.splice(0).map(bridge => bridge.stop()))
for (const socket of sockets) socket.destroy()
sockets.clear()
await Promise.all(servers.splice(0).map(server => closeServer(server)))
})
describe('SystemProxyBridge', () => {
it('preserves Chromium proxy fallback order and supported rule types', () => {
expect(parseSystemProxyRules(
'HTTPS secure.example:8443; PROXY plain.example:8080; SOCKS4 old.example:1080; SOCKS5 socks.example:1081; DIRECT',
)).toEqual([
{ type: 'https', host: 'secure.example', port: 8443 },
{ type: 'http', host: 'plain.example', port: 8080 },
{ type: 'socks4', host: 'old.example', port: 1080 },
{ type: 'socks5', host: 'socks.example', port: 1081 },
{ type: 'direct' },
])
expect(parseSystemProxyRules('SOCKS socks.example:1080; DIRECT')[0]?.type).toBe('socks4')
expect(parseSystemProxyRules('')).toEqual([{ type: 'direct' }])
expect(parseSystemProxyRules('INVALID proxy.example:1234')).toEqual([])
})
it('removes proxy credentials and declared hop-by-hop headers', () => {
expect(sanitizeProxyRequestHeaders({
connection: 'keep-alive, x-remove-me',
'proxy-authorization': 'Basic secret',
'proxy-connection': 'keep-alive',
'x-remove-me': 'private-hop',
})).toEqual({})
})
it('resolves PAC rules per target and observes runtime changes without restart', async () => {
const firstProxy = await createProxyServer('first')
const secondProxy = await createProxyServer('second')
let selectedPort = firstProxy
const resolver = vi.fn(async () => `PROXY 127.0.0.1:${selectedPort}; DIRECT`)
const bridge = await startBridge(resolver)
expect(await requestThroughProxy(bridge, 'http://foreign.example/one')).toBe('first')
selectedPort = secondProxy
expect(await requestThroughProxy(bridge, 'http://foreign.example/two')).toBe('second')
expect(resolver).toHaveBeenNthCalledWith(1, 'http://foreign.example/one')
expect(resolver).toHaveBeenNthCalledWith(2, 'http://foreign.example/two')
})
it('falls back to the next PAC route when the preferred proxy cannot connect', async () => {
const unavailablePort = await reserveClosedPort()
const workingProxy = await createProxyServer('fallback')
const bridge = await startBridge(async () => (
`PROXY 127.0.0.1:${unavailablePort}; PROXY 127.0.0.1:${workingProxy}; DIRECT`
))
expect(await requestThroughProxy(bridge, 'http://fallback.example/value')).toBe('fallback')
})
it('falls back when a connected proxy drops the request before responding', async () => {
const droppingProxy = net.createServer(socket => {
socket.once('data', () => socket.destroy())
})
servers.push(droppingProxy)
const droppingPort = await listen(droppingProxy)
const workingProxy = await createProxyServer('request-fallback')
const bridge = await startBridge(async () => (
`PROXY 127.0.0.1:${droppingPort}; PROXY 127.0.0.1:${workingProxy}; DIRECT`
))
expect(await requestThroughProxy(bridge, 'http://fallback.example/after-drop'))
.toBe('request-fallback')
})
it('does not replay a non-idempotent request after its selected proxy drops it', async () => {
let firstProxyRequests = 0
const droppingProxy = net.createServer(socket => {
socket.once('data', () => {
firstProxyRequests++
socket.destroy()
})
})
servers.push(droppingProxy)
const droppingPort = await listen(droppingProxy)
let fallbackRequests = 0
const fallbackProxy = http.createServer((_request, response) => {
fallbackRequests++
response.end('must-not-replay')
})
servers.push(fallbackProxy)
const fallbackPort = await listen(fallbackProxy)
const bridge = await startBridge(async () => (
`PROXY 127.0.0.1:${droppingPort}; PROXY 127.0.0.1:${fallbackPort}; DIRECT`
))
await expect(requestThroughProxy(bridge, 'http://foreign.example/model', 'POST', 'prompt'))
.rejects.toThrow('HTTP 502')
expect(firstProxyRequests).toBe(1)
expect(fallbackRequests).toBe(0)
})
it('removes proxy credentials and hop-by-hop headers before forwarding', async () => {
let receivedHeaders: http.IncomingHttpHeaders = {}
const proxy = http.createServer((request, response) => {
receivedHeaders = request.headers
response.end('sanitized')
})
servers.push(proxy)
const proxyPort = await listen(proxy)
const bridge = await startBridge(async () => `PROXY 127.0.0.1:${proxyPort}`)
expect(await requestThroughProxy(
bridge,
'http://foreign.example/value',
'GET',
undefined,
{
Connection: 'keep-alive, x-remove-me',
'Proxy-Authorization': 'Basic secret',
'Proxy-Connection': 'keep-alive',
'X-Remove-Me': 'private-hop',
},
)).toBe('sanitized')
expect(receivedHeaders['proxy-authorization']).toBeUndefined()
expect(receivedHeaders['x-remove-me']).toBeUndefined()
})
it('preserves fallback order for CONNECT tunnels', async () => {
const echoServer = net.createServer(socket => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.pipe(socket)
})
servers.push(echoServer)
const echoPort = await listen(echoServer)
const tunnelProxy = http.createServer()
tunnelProxy.on('connect', (_request, clientSocket, head) => {
const targetSocket = net.connect({ host: '127.0.0.1', port: echoPort }, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length > 0) targetSocket.write(head)
targetSocket.pipe(clientSocket)
clientSocket.pipe(targetSocket)
})
targetSocket.on('error', () => clientSocket.destroy())
})
servers.push(tunnelProxy)
const proxyPort = await listen(tunnelProxy)
const unavailablePort = await reserveClosedPort()
const resolver = vi.fn(async () => (
`PROXY 127.0.0.1:${unavailablePort}; PROXY 127.0.0.1:${proxyPort}; DIRECT`
))
const bridge = await startBridge(resolver)
await expect(connectAndEcho(bridge, 'foreign.example:443', 'tunnel-ok'))
.resolves.toBe('tunnel-ok')
expect(resolver).toHaveBeenCalledWith('https://foreign.example/')
})
it('surfaces proxy authentication requirements without falling through to DIRECT', async () => {
const authProxy = http.createServer()
authProxy.on('connect', (_request, socket) => {
socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nConnection: close\r\n\r\n')
})
servers.push(authProxy)
const proxyPort = await listen(authProxy)
const bridge = await startBridge(async () => `PROXY 127.0.0.1:${proxyPort}; DIRECT`)
await expect(connectAndEcho(bridge, 'foreign.example:443', 'unused'))
.rejects.toThrow('407 Proxy Authentication Required')
})
it('always bypasses system proxy resolution for loopback targets', async () => {
const target = http.createServer((_request, response) => response.end('loopback'))
servers.push(target)
const targetPort = await listen(target)
const resolver = vi.fn(async () => 'PROXY unreachable.example:8080')
const bridge = await startBridge(resolver)
await expect(requestThroughProxy(bridge, `http://127.0.0.1:${targetPort}/health`))
.resolves.toBe('loopback')
expect(resolver).not.toHaveBeenCalled()
})
it('binds the bridge only to IPv4 loopback', async () => {
const bridgeUrl = await startBridge(async () => 'DIRECT')
expect(new URL(bridgeUrl).hostname).toBe('127.0.0.1')
})
it('does not publish a listener when stop races with startup', async () => {
const bridge = new SystemProxyBridge(async () => 'DIRECT')
bridges.push(bridge)
const startup = bridge.start()
await bridge.stop()
await expect(startup).rejects.toThrow('startup was stopped')
const restartedUrl = await bridge.start()
expect(new URL(restartedUrl).hostname).toBe('127.0.0.1')
})
it('closes active CONNECT clients and outbound routes during stop', async () => {
let acceptTarget!: () => void
const targetAccepted = new Promise<void>(resolve => { acceptTarget = resolve })
let closeTarget!: () => void
const targetClosed = new Promise<void>(resolve => { closeTarget = resolve })
const target = net.createServer(socket => {
acceptTarget()
socket.once('close', closeTarget)
})
servers.push(target)
const targetPort = await listen(target)
const bridge = new SystemProxyBridge(async () => 'DIRECT')
bridges.push(bridge)
const bridgeUrl = await bridge.start()
const client = await openConnectTunnel(bridgeUrl, `127.0.0.1:${targetPort}`)
sockets.add(client)
client.once('close', () => sockets.delete(client))
await targetAccepted
await expect(Promise.race([
bridge.stop().then(() => 'stopped'),
new Promise<string>(resolve => setTimeout(() => resolve('timed out'), 500)),
])).resolves.toBe('stopped')
await expect(Promise.race([
targetClosed.then(() => 'closed'),
new Promise<string>(resolve => setTimeout(() => resolve('timed out'), 500)),
])).resolves.toBe('closed')
})
})
async function startBridge(
resolver: (url: string) => Promise<string>,
): Promise<string> {
const bridge = new SystemProxyBridge(resolver)
bridges.push(bridge)
return await bridge.start()
}
async function createProxyServer(label: string): Promise<number> {
const server = http.createServer((_request, response) => response.end(label))
servers.push(server)
return await listen(server)
}
function listen(server: http.Server | net.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
reject(new Error('Could not resolve test server port'))
return
}
resolve(address.port)
})
})
}
function closeServer(server: http.Server | net.Server): Promise<void> {
return new Promise(resolve => {
if ('closeAllConnections' in server) server.closeAllConnections()
server.close(() => resolve())
})
}
async function reserveClosedPort(): Promise<number> {
const server = net.createServer()
const port = await listen(server)
await closeServer(server)
const index = servers.indexOf(server)
if (index >= 0) servers.splice(index, 1)
return port
}
function requestThroughProxy(
proxyUrl: string,
targetUrl: string,
method = 'GET',
body?: string,
extraHeaders: Record<string, string> = {},
): Promise<string> {
const proxy = new URL(proxyUrl)
return new Promise((resolve, reject) => {
const request = http.request({
host: proxy.hostname,
port: Number(proxy.port),
path: targetUrl,
method,
headers: { Host: new URL(targetUrl).host, ...extraHeaders },
}, response => {
const chunks: Buffer[] = []
response.on('data', chunk => chunks.push(Buffer.from(chunk)))
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`Proxy returned HTTP ${response.statusCode}: ${Buffer.concat(chunks).toString()}`))
return
}
resolve(Buffer.concat(chunks).toString())
})
})
request.on('error', reject)
request.end(body)
})
}
function connectAndEcho(
proxyUrl: string,
authority: string,
payload: string,
): Promise<string> {
const proxy = new URL(proxyUrl)
return new Promise((resolve, reject) => {
const socket = net.connect({ host: proxy.hostname, port: Number(proxy.port) })
let connected = false
let buffered = Buffer.alloc(0)
socket.once('connect', () => {
socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`)
})
socket.on('data', chunk => {
buffered = Buffer.concat([buffered, typeof chunk === 'string' ? Buffer.from(chunk) : chunk])
if (!connected) {
const headerEnd = buffered.indexOf('\r\n\r\n')
if (headerEnd < 0) return
const header = buffered.subarray(0, headerEnd).toString()
if (!header.includes(' 200 ')) {
socket.destroy()
reject(new Error(`CONNECT failed: ${header}`))
return
}
connected = true
buffered = buffered.subarray(headerEnd + 4)
socket.write(payload)
}
if (connected && buffered.length >= Buffer.byteLength(payload)) {
const result = buffered.subarray(0, Buffer.byteLength(payload)).toString()
socket.destroy()
resolve(result)
}
})
socket.once('error', reject)
})
}
function openConnectTunnel(proxyUrl: string, authority: string): Promise<net.Socket> {
const proxy = new URL(proxyUrl)
return new Promise((resolve, reject) => {
const socket = net.connect({ host: proxy.hostname, port: Number(proxy.port) })
let buffered = Buffer.alloc(0)
socket.once('connect', () => {
socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`)
})
const onData = (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk])
const headerEnd = buffered.indexOf('\r\n\r\n')
if (headerEnd < 0) return
socket.off('data', onData)
const header = buffered.subarray(0, headerEnd).toString()
if (!header.includes(' 200 ')) {
socket.destroy()
reject(new Error(`CONNECT failed: ${header}`))
return
}
resolve(socket)
}
socket.on('data', onData)
socket.once('error', reject)
})
}

View File

@ -0,0 +1,681 @@
import http from 'node:http'
import https from 'node:https'
import net from 'node:net'
import tls from 'node:tls'
import { lookup } from 'node:dns/promises'
import type { Duplex } from 'node:stream'
export const SYSTEM_PROXY_BRIDGE_HOST = '127.0.0.1'
const CONNECT_TIMEOUT_MS = 10_000
const MAX_BUFFERED_REQUEST_BYTES = 32 * 1024 * 1024
export type SystemProxyRule = {
type: 'direct' | 'http' | 'https' | 'socks4' | 'socks5'
host?: string
port?: number
}
export type SystemProxyBridgeLike = {
start(): Promise<string>
stop(): Promise<void>
}
type ProxyEndpoint = Required<Pick<SystemProxyRule, 'host' | 'port'>>
type OutgoingRequestOptions = http.RequestOptions & { servername?: string }
export function parseSystemProxyRules(rules: string | undefined): SystemProxyRule[] {
if (!rules?.trim()) return [{ type: 'direct' }]
const parsed: SystemProxyRule[] = []
for (const rawRule of rules.split(';')) {
const rule = rawRule.trim()
if (!rule) continue
if (/^DIRECT$/i.test(rule)) {
parsed.push({ type: 'direct' })
continue
}
const match = rule.match(/^(PROXY|HTTPS|SOCKS|SOCKS4|SOCKS5)\s+(.+)$/i)
if (!match) continue
const endpoint = parseEndpoint(match[2]!)
if (!endpoint) continue
const kind = match[1]!.toUpperCase()
parsed.push({
type: kind === 'PROXY'
? 'http'
: kind === 'HTTPS'
? 'https'
: kind === 'SOCKS4'
? 'socks4'
: kind === 'SOCKS5'
? 'socks5'
: 'socks4',
...endpoint,
})
}
return parsed
}
export class SystemProxyBridge implements SystemProxyBridgeLike {
private server: http.Server | null = null
private startPromise: Promise<string> | null = null
private lifecycleGeneration = 0
private readonly clientSockets = new Set<net.Socket>()
private readonly outboundSockets = new Set<Duplex>()
constructor(private readonly resolveSystemProxy: (url: string) => Promise<string>) {}
start(): Promise<string> {
if (this.startPromise) return this.startPromise
const generation = ++this.lifecycleGeneration
this.startPromise = this.startOnce(generation)
return this.startPromise
}
async stop(): Promise<void> {
++this.lifecycleGeneration
const startPromise = this.startPromise
this.startPromise = null
const server = this.server
this.server = null
const closing = server?.listening
? new Promise<void>(resolve => server.close(() => resolve()))
: Promise.resolve()
for (const socket of this.clientSockets) socket.destroy()
for (const socket of this.outboundSockets) socket.destroy()
await closing
await startPromise?.catch(() => {})
}
private async startOnce(generation: number): Promise<string> {
const server = http.createServer((request, response) => {
void this.handleHttpRequest(request, response)
})
server.on('connect', (request, clientSocket, head) => {
void this.handleConnect(request, clientSocket, head)
})
server.on('clientError', (_error, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
})
server.on('connection', socket => {
this.clientSockets.add(socket)
socket.once('close', () => this.clientSockets.delete(socket))
})
this.server = server
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, SYSTEM_PROXY_BRIDGE_HOST, () => {
server.off('error', reject)
resolve()
})
})
} catch (error) {
if (this.server === server) this.server = null
throw error
}
if (generation !== this.lifecycleGeneration || this.server !== server) {
await closeServer(server)
throw new Error('System proxy bridge startup was stopped')
}
const address = server.address()
if (!address || typeof address === 'string') {
if (this.server === server) this.server = null
await closeServer(server)
throw new Error('Could not resolve system proxy bridge port')
}
return `http://${SYSTEM_PROXY_BRIDGE_HOST}:${address.port}`
}
private async handleHttpRequest(
request: http.IncomingMessage,
response: http.ServerResponse,
): Promise<void> {
try {
const target = resolveHttpTarget(request)
if (target.protocol !== 'http:') {
response.writeHead(400, { Connection: 'close' })
response.end('HTTPS proxy requests must use CONNECT')
return
}
const rules = await this.resolveRules(target)
const method = request.method ?? 'GET'
const headers = sanitizeProxyRequestHeaders(request.headers)
const onSocket = (socket: Duplex) => this.trackOutboundSocket(socket)
// Model calls are usually POSTs. Never replay them after bytes may have reached a provider;
// only explicitly safe methods may retry a later PAC route before any response is received.
const upstreamResponse = isReplaySafeMethod(method)
? await requestUsingRules(
rules,
target,
method,
headers,
await readRequestBody(request),
onSocket,
)
: await requestStreamingUsingRule(
await selectReachableRule(rules, target),
target,
method,
headers,
request,
onSocket,
)
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.statusMessage, upstreamResponse.headers)
upstreamResponse.pipe(response)
} catch (error) {
if (!response.headersSent) response.writeHead(502, { Connection: 'close' })
response.end(`System proxy bridge failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async handleConnect(
request: http.IncomingMessage,
clientSocket: Duplex,
head: Buffer,
): Promise<void> {
try {
const endpoint = parseEndpoint(request.url ?? '', 443)
if (!endpoint) throw new Error('Invalid CONNECT target')
const target = new URL(`https://${formatAuthority(endpoint.host, endpoint.port)}/`)
const rules = await this.resolveRules(target)
const route = await connectTunnelUsingRules(rules, endpoint.host, endpoint.port)
this.trackOutboundSocket(route.socket)
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length > 0) route.socket.write(head)
route.socket.pipe(clientSocket)
clientSocket.pipe(route.socket)
const closeBoth = () => {
route.socket.destroy()
clientSocket.destroy()
}
route.socket.on('error', closeBoth)
clientSocket.on('error', closeBoth)
} catch (error) {
const authenticationRequired = error instanceof ProxyAuthenticationRequiredError
clientSocket.end(`${authenticationRequired
? 'HTTP/1.1 407 Proxy Authentication Required'
: 'HTTP/1.1 502 Bad Gateway'}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${error instanceof Error ? error.message : String(error)}`)
}
}
private async resolveRules(target: URL): Promise<SystemProxyRule[]> {
if (isLoopbackHostname(target.hostname)) return [{ type: 'direct' }]
return parseSystemProxyRules(await this.resolveSystemProxy(target.href))
}
private trackOutboundSocket(socket: Duplex): void {
this.outboundSockets.add(socket)
socket.once('close', () => this.outboundSockets.delete(socket))
}
}
function resolveHttpTarget(request: http.IncomingMessage): URL {
const rawUrl = request.url ?? ''
if (/^https?:\/\//i.test(rawUrl)) return new URL(rawUrl)
const host = request.headers.host
if (!host) throw new Error('Proxy request is missing Host header')
return new URL(rawUrl || '/', `http://${host}`)
}
export function sanitizeProxyRequestHeaders(headers: http.IncomingHttpHeaders): http.IncomingHttpHeaders {
const sanitized = { ...headers }
const connectionTokens = String(headers.connection ?? '')
.split(',')
.map(token => token.trim().toLowerCase())
.filter(Boolean)
for (const name of [
...connectionTokens,
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]) {
delete sanitized[name]
}
return sanitized
}
function isReplaySafeMethod(method: string): boolean {
return ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())
}
async function requestUsingRules(
rules: SystemProxyRule[],
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
body: Buffer,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
const errors: string[] = []
for (const rule of rules) {
try {
return await requestUsingRule(rule, target, method, headers, body, onSocket)
} catch (error) {
errors.push(`${rule.type}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`No system proxy route succeeded (${errors.join('; ')})`)
}
async function requestUsingRule(
rule: SystemProxyRule,
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
body: Buffer,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
const outgoing = await createRuleRequest(rule, target, method, headers, onSocket)
return await performHttpRequest(outgoing.transport, outgoing.options, body, onSocket)
}
async function requestStreamingUsingRule(
rule: SystemProxyRule,
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
body: http.IncomingMessage,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
const outgoing = await createRuleRequest(rule, target, method, headers, onSocket)
return await new Promise((resolve, reject) => {
const request = outgoing.transport.request(outgoing.options, resolve)
request.once('socket', onSocket)
request.once('error', reject)
body.once('error', error => request.destroy(error))
body.pipe(request)
})
}
async function createRuleRequest(
rule: SystemProxyRule,
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
onSocket: (socket: Duplex) => void,
): Promise<{ transport: typeof http | typeof https, options: OutgoingRequestOptions }> {
const outgoingHeaders = { ...headers, connection: 'close' }
if (rule.type === 'direct') {
return {
transport: http,
options: {
method,
host: target.hostname,
port: targetPort(target),
path: `${target.pathname}${target.search}`,
headers: outgoingHeaders,
agent: false,
},
}
}
const endpoint = requireEndpoint(rule)
if (rule.type === 'http' || rule.type === 'https') {
return {
transport: rule.type === 'https' ? https : http,
options: {
method,
host: endpoint.host,
port: endpoint.port,
path: target.href,
headers: outgoingHeaders,
agent: false,
servername: net.isIP(endpoint.host) ? undefined : endpoint.host,
},
}
}
const socket = rule.type === 'socks4'
? await connectSocks4(endpoint, target.hostname, targetPort(target))
: await connectSocks5(endpoint, target.hostname, targetPort(target))
onSocket(socket)
return {
transport: http,
options: {
method,
host: target.hostname,
port: targetPort(target),
path: `${target.pathname}${target.search}`,
headers: outgoingHeaders,
agent: new SingleSocketAgent(socket),
},
}
}
async function selectReachableRule(rules: SystemProxyRule[], target: URL): Promise<SystemProxyRule> {
const errors: string[] = []
for (const rule of rules) {
let socket: Duplex | null = null
try {
if (rule.type === 'direct') {
socket = await connectTcp(target.hostname, targetPort(target))
} else {
const endpoint = requireEndpoint(rule)
socket = rule.type === 'http' || rule.type === 'https'
? await connectProxyEndpoint(endpoint, rule.type === 'https')
: rule.type === 'socks4'
? await connectSocks4(endpoint, target.hostname, targetPort(target))
: await connectSocks5(endpoint, target.hostname, targetPort(target))
}
socket.destroy()
return rule
} catch (error) {
socket?.destroy()
errors.push(`${rule.type}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`No system proxy route succeeded (${errors.join('; ')})`)
}
function performHttpRequest(
transport: typeof http | typeof https,
options: OutgoingRequestOptions,
body: Buffer,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
return new Promise((resolve, reject) => {
const request = transport.request(options, resolve)
request.once('socket', onSocket)
request.once('error', reject)
request.end(body)
})
}
async function readRequestBody(request: http.IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
let total = 0
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
total += buffer.length
if (total > MAX_BUFFERED_REQUEST_BYTES) {
throw new Error(`proxy request body exceeds ${MAX_BUFFERED_REQUEST_BYTES} bytes`)
}
chunks.push(buffer)
}
return Buffer.concat(chunks, total)
}
class SingleSocketAgent extends http.Agent {
private claimed = false
constructor(private readonly socket: net.Socket) {
super({ keepAlive: false })
}
override createConnection(): net.Socket {
if (this.claimed) throw new Error('System proxy route socket was already claimed')
this.claimed = true
return this.socket
}
}
function closeServer(server: http.Server): Promise<void> {
if (!server.listening) return Promise.resolve()
return new Promise(resolve => server.close(() => resolve()))
}
async function connectTunnelUsingRules(
rules: SystemProxyRule[],
targetHost: string,
targetPortNumber: number,
): Promise<{ socket: Duplex }> {
const errors: string[] = []
for (const rule of rules) {
try {
if (rule.type === 'direct') {
return { socket: await connectTcp(targetHost, targetPortNumber) }
}
const endpoint = requireEndpoint(rule)
if (rule.type === 'http' || rule.type === 'https') {
return {
socket: await establishHttpProxyTunnel(
endpoint,
rule.type === 'https',
targetHost,
targetPortNumber,
),
}
}
return {
socket: rule.type === 'socks4'
? await connectSocks4(endpoint, targetHost, targetPortNumber)
: await connectSocks5(endpoint, targetHost, targetPortNumber),
}
} catch (error) {
if (error instanceof ProxyAuthenticationRequiredError) throw error
errors.push(`${rule.type}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`No system proxy route succeeded (${errors.join('; ')})`)
}
async function establishHttpProxyTunnel(
endpoint: ProxyEndpoint,
secure: boolean,
targetHost: string,
targetPortNumber: number,
): Promise<Duplex> {
const socket = await connectProxyEndpoint(endpoint, secure)
try {
const authority = formatAuthority(targetHost, targetPortNumber)
socket.write(
`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Connection: keep-alive\r\n\r\n`,
)
const header = await readUntil(socket, Buffer.from('\r\n\r\n'), 64 * 1024)
const statusLine = header.toString('latin1').split('\r\n', 1)[0] ?? ''
const status = Number(statusLine.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})\b/i)?.[1])
if (status === 407) {
throw new ProxyAuthenticationRequiredError('HTTP proxy requires authentication')
}
if (status < 200 || status >= 300) {
throw new Error(`HTTP proxy CONNECT returned ${Number.isFinite(status) ? status : 'an invalid response'}`)
}
return socket
} catch (error) {
socket.destroy()
throw error
}
}
function connectProxyEndpoint(endpoint: ProxyEndpoint, secure: boolean): Promise<Duplex> {
return secure ? connectTls(endpoint.host, endpoint.port) : connectTcp(endpoint.host, endpoint.port)
}
function connectTcp(host: string, port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port })
const timer = setTimeout(() => socket.destroy(new Error('connection timed out')), CONNECT_TIMEOUT_MS)
socket.once('connect', () => {
clearTimeout(timer)
resolve(socket)
})
socket.once('error', error => {
clearTimeout(timer)
reject(error)
})
})
}
function connectTls(host: string, port: number): Promise<tls.TLSSocket> {
return new Promise((resolve, reject) => {
const socket = tls.connect({ host, port, servername: net.isIP(host) ? undefined : host })
const timer = setTimeout(() => socket.destroy(new Error('connection timed out')), CONNECT_TIMEOUT_MS)
socket.once('secureConnect', () => {
clearTimeout(timer)
resolve(socket)
})
socket.once('error', error => {
clearTimeout(timer)
reject(error)
})
})
}
async function connectSocks5(
endpoint: ProxyEndpoint,
targetHost: string,
targetPortNumber: number,
): Promise<net.Socket> {
const socket = await connectTcp(endpoint.host, endpoint.port)
try {
socket.write(Buffer.from([0x05, 0x01, 0x00]))
const greeting = await readExactly(socket, 2)
if (greeting[0] !== 0x05 || greeting[1] !== 0x00) throw new Error('SOCKS5 proxy rejected no-authentication mode')
const host = Buffer.from(targetHost)
if (host.length > 255) throw new Error('SOCKS5 target hostname is too long')
const port = Buffer.allocUnsafe(2)
port.writeUInt16BE(targetPortNumber)
socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, host.length]), host, port]))
const response = await readExactly(socket, 4)
if (response[0] !== 0x05 || response[1] !== 0x00) throw new Error(`SOCKS5 connect failed with code ${response[1]}`)
const addressLength = response[3] === 0x01
? 4
: response[3] === 0x04
? 16
: response[3] === 0x03
? (await readExactly(socket, 1))[0]!
: 0
if (!addressLength) throw new Error('SOCKS5 proxy returned an invalid address type')
await readExactly(socket, addressLength + 2)
return socket
} catch (error) {
socket.destroy()
throw error
}
}
async function connectSocks4(
endpoint: ProxyEndpoint,
targetHost: string,
targetPortNumber: number,
): Promise<net.Socket> {
const socket = await connectTcp(endpoint.host, endpoint.port)
try {
const port = Buffer.allocUnsafe(2)
port.writeUInt16BE(targetPortNumber)
const address = net.isIPv4(targetHost)
? targetHost
: (await lookup(targetHost, { family: 4 })).address
const octets = address.split('.').map(Number)
if (octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
throw new Error('SOCKS4 target did not resolve to IPv4')
}
socket.write(Buffer.concat([
Buffer.from([0x04, 0x01]),
port,
Buffer.from(octets),
Buffer.from([0x00]),
]))
const response = await readExactly(socket, 8)
if (response[1] !== 0x5a) throw new Error(`SOCKS4 connect failed with code ${response[1]}`)
return socket
} catch (error) {
socket.destroy()
throw error
}
}
function readExactly(socket: net.Socket, length: number): Promise<Buffer> {
return readFromSocket(socket, buffer => buffer.length >= length ? length : null)
}
function readUntil(socket: Duplex, marker: Buffer, maxBytes: number): Promise<Buffer> {
return readFromSocket(socket, buffer => {
const index = buffer.indexOf(marker)
if (index >= 0) return index + marker.length
if (buffer.length > maxBytes) throw new Error('proxy response headers are too large')
return null
})
}
function readFromSocket(
socket: Duplex,
completeLength: (buffer: Buffer) => number | null,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
let buffered = Buffer.alloc(0)
const cleanup = () => {
socket.off('data', onData)
socket.off('error', onError)
socket.off('close', onClose)
clearTimeout(timer)
}
const onData = (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk])
let length: number | null
try {
length = completeLength(buffered)
} catch (error) {
cleanup()
reject(error)
return
}
if (length === null) return
cleanup()
if (buffered.length > length) socket.unshift(buffered.subarray(length))
resolve(buffered.subarray(0, length))
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const onClose = () => {
cleanup()
reject(new Error('proxy connection closed during handshake'))
}
const timer = setTimeout(() => {
cleanup()
reject(new Error('proxy handshake timed out'))
}, CONNECT_TIMEOUT_MS)
socket.on('data', onData)
socket.once('error', onError)
socket.once('close', onClose)
})
}
function parseEndpoint(value: string, defaultPort?: number): ProxyEndpoint | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
const url = new URL(`tcp://${trimmed}`)
const port = url.port ? Number(url.port) : defaultPort
if (!url.hostname || !port || !Number.isInteger(port) || port < 1 || port > 65535) return null
return { host: stripIpv6Brackets(url.hostname), port }
} catch {
return null
}
}
function requireEndpoint(rule: SystemProxyRule): ProxyEndpoint {
if (!rule.host || !rule.port) throw new Error(`Invalid ${rule.type} proxy endpoint`)
return { host: rule.host, port: rule.port }
}
function targetPort(url: URL): number {
return url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = stripIpv6Brackets(hostname).toLowerCase()
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'
}
function stripIpv6Brackets(hostname: string): string {
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
}
function formatAuthority(host: string, port: number): string {
return `${host.includes(':') ? `[${host}]` : host}:${port}`
}
class ProxyAuthenticationRequiredError extends Error {}

View File

@ -450,7 +450,7 @@ describe('Settings > General tab', () => {
expect(screen.getByRole('button', { name: /Direct connection/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: /System proxy/i })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Manual proxy/i }))
fireEvent.click(screen.getByRole('button', { name: /^Manual proxy/i }))
const proxyInput = screen.getByLabelText('Proxy URL')
const saveButton = screen.getAllByRole('button', { name: 'Save' })[0]!

View File

@ -1209,13 +1209,13 @@ export const en = {
'settings.general.h5AccessStaleHostApply': 'Switch to {suggestedHost}',
'settings.general.h5AccessProxyNote': 'Currently using a reverse proxy URL. Phones reach the desktop through the public domain / tunnel you configured. Make sure your proxy is still up.',
'settings.general.networkTitle': 'Network',
'settings.general.networkDescription': 'Controls provider API requests made by desktop sessions.',
'settings.general.networkDescription': 'Controls outbound network access for desktop AI sessions, including providers, sign-in, MCP, and agent tools.',
'settings.general.networkProxyModeDirect': 'Direct connection',
'settings.general.networkProxyModeDirectDescription': 'Do not use inherited proxy settings for provider API requests.',
'settings.general.networkProxyModeDirectDescription': 'Explicitly bypass system and inherited proxy settings.',
'settings.general.networkProxyModeSystem': 'System proxy',
'settings.general.networkProxyModeSystemDescription': 'Use proxy settings inherited by the app process.',
'settings.general.networkProxyModeSystemDescription': 'Follow OS proxy or PAC routing per destination and pick up changes automatically. Use Manual proxy when credentials are required.',
'settings.general.networkProxyModeManual': 'Manual proxy',
'settings.general.networkProxyModeManualDescription': 'Use the HTTP or HTTPS proxy URL entered below.',
'settings.general.networkProxyModeManualDescription': 'Route session traffic through the HTTP or HTTPS proxy URL entered below.',
'settings.general.networkProxyUrl': 'Proxy URL',
'settings.general.networkProxyUrlHint': 'HTTP and HTTPS proxy URLs are supported. For authenticated proxies, use http://user:password@127.0.0.1:7890; the URL is saved with network settings.',
'settings.general.networkProxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.',
@ -1229,7 +1229,7 @@ export const en = {
'settings.general.networkTimeoutRequired': 'Enter a timeout.',
'settings.general.networkTimeoutRange': 'Enter a whole number from {min} to {max} seconds.',
'settings.general.networkSaved': 'Network settings saved.',
'settings.general.networkScopeHint': 'Does not change the separate app update proxy.',
'settings.general.networkScopeHint': 'Applies to new requests; an in-flight request keeps its current route. App updates use a separate proxy setting.',
'settings.general.networkSave': 'Save',
'settings.general.webFetchPreflightTitle': 'WebFetch Preflight',
'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.',

View File

@ -1211,13 +1211,13 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.h5AccessStaleHostApply': '{suggestedHost} に切り替え',
'settings.general.h5AccessProxyNote': '現在リバースプロキシ URL を使用しています。スマートフォンは、設定した公開ドメイン / トンネルを通じてデスクトップに到達します。プロキシが稼働中であることを確認してください。',
'settings.general.networkTitle': 'ネットワーク',
'settings.general.networkDescription': 'デスクトップセッションが行うプロバイダー API リクエストを制御します。',
'settings.general.networkDescription': 'プロバイダー、サインイン、MCP、エージェントツールを含むデスクトップ AI セッションの外部通信を制御します。',
'settings.general.networkProxyModeDirect': '直接接続',
'settings.general.networkProxyModeDirectDescription': 'プロバイダー API リクエストに継承されたプロキシ設定を使用しません。',
'settings.general.networkProxyModeDirectDescription': 'システムおよびプロセスから継承したプロキシを明示的に使用しません。',
'settings.general.networkProxyModeSystem': 'システムプロキシ',
'settings.general.networkProxyModeSystemDescription': 'アプリプロセスが継承したプロキシ設定を使用します。',
'settings.general.networkProxyModeSystemDescription': '宛先ごとに OS のプロキシまたは PAC ルールに従い、変更も自動反映します。認証情報が必要な場合は手動プロキシを使用してください。',
'settings.general.networkProxyModeManual': '手動プロキシ',
'settings.general.networkProxyModeManualDescription': '以下に入力した HTTP または HTTPS プロキシ URL を使用します。',
'settings.general.networkProxyModeManualDescription': 'セッション通信を以下の HTTP または HTTPS プロキシ URL 経由で送信します。',
'settings.general.networkProxyUrl': 'プロキシ URL',
'settings.general.networkProxyUrlHint': 'HTTP および HTTPS プロキシ URL に対応しています。例: http://127.0.0.1:7890。',
'settings.general.networkProxyUrlInvalid': 'HTTP または HTTPS のプロキシ URL を入力してください。',
@ -1231,7 +1231,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.networkTimeoutRequired': 'タイムアウトを入力してください。',
'settings.general.networkTimeoutRange': '{min} から {max} 秒までの整数を入力してください。',
'settings.general.networkSaved': 'ネットワーク設定を保存しました。',
'settings.general.networkScopeHint': '別途設定するアプリ更新用プロキシは変更されません。',
'settings.general.networkScopeHint': '新しいリクエストに適用され、進行中のリクエストは現在の経路を維持します。アプリ更新は別のプロキシ設定を使用します。',
'settings.general.networkSave': '保存',
'settings.general.webFetchPreflightTitle': 'WebFetch プリフライト',
'settings.general.webFetchPreflightDescription': 'デスクトップセッションは、サードパーティプロバイダーや制限されたネットワークでの誤った失敗を避けるため、デフォルトで Claude のドメインプリフライトをスキップします。',

View File

@ -1211,13 +1211,13 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.h5AccessStaleHostApply': '{suggestedHost}(으)로 전환',
'settings.general.h5AccessProxyNote': '현재 리버스 프록시 URL을 사용하고 있습니다. 휴대폰은 구성한 공개 도메인 / 터널을 통해 데스크톱에 연결됩니다. 프록시가 계속 작동 중인지 확인하세요.',
'settings.general.networkTitle': '네트워크',
'settings.general.networkDescription': '데스크톱 세션이 수행하는 공급자 API 요청을 제어합니다.',
'settings.general.networkDescription': '공급자, 로그인, MCP 및 에이전트 도구를 포함한 데스크톱 AI 세션의 외부 네트워크를 제어합니다.',
'settings.general.networkProxyModeDirect': '직접 연결',
'settings.general.networkProxyModeDirectDescription': '공급자 API 요청에 상속된 프록시 설정을 사용하지 않습니다.',
'settings.general.networkProxyModeDirectDescription': '시스템 및 프로세스에서 상속된 프록시를 명시적으로 우회합니다.',
'settings.general.networkProxyModeSystem': '시스템 프록시',
'settings.general.networkProxyModeSystemDescription': '앱 프로세스가 상속한 프록시 설정을 사용합니다.',
'settings.general.networkProxyModeSystemDescription': '대상별로 OS 프록시 또는 PAC 규칙을 따르며 변경 사항을 자동 반영합니다. 인증 정보가 필요하면 수동 프록시를 사용하세요.',
'settings.general.networkProxyModeManual': '수동 프록시',
'settings.general.networkProxyModeManualDescription': '아래에 입력한 HTTP 또는 HTTPS 프록시 URL을 사용합니다.',
'settings.general.networkProxyModeManualDescription': '세션 트래픽을 아래에 입력한 HTTP 또는 HTTPS 프록시 URL로 라우팅합니다.',
'settings.general.networkProxyUrl': '프록시 URL',
'settings.general.networkProxyUrlHint': 'HTTP 및 HTTPS 프록시 URL을 지원합니다. 예: http://127.0.0.1:7890.',
'settings.general.networkProxyUrlInvalid': 'HTTP 또는 HTTPS 프록시 URL을 입력하세요.',
@ -1231,7 +1231,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.networkTimeoutRequired': '시간 초과를 입력하세요.',
'settings.general.networkTimeoutRange': '{min}에서 {max}초까지의 정수를 입력하세요.',
'settings.general.networkSaved': '네트워크 설정을 저장했습니다.',
'settings.general.networkScopeHint': '별도의 앱 업데이트 프록시는 변경되지 않습니다.',
'settings.general.networkScopeHint': '새 요청에 적용되며 진행 중인 요청은 현재 경로를 유지합니다. 앱 업데이트는 별도 프록시 설정을 사용합니다.',
'settings.general.networkSave': '저장',
'settings.general.webFetchPreflightTitle': 'WebFetch 사전 확인',
'settings.general.webFetchPreflightDescription': '데스크톱 세션은 타사 공급자와 제한된 네트워크에서의 잘못된 실패를 피하기 위해 기본적으로 Claude의 도메인 사전 확인을 건너뜁니다.',

View File

@ -1211,13 +1211,13 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessStaleHostApply': '切到 {suggestedHost}',
'settings.general.h5AccessProxyNote': '當前使用的是反向代理 URL手機掃碼會經公網/隧道返回桌面端。請確認你的反代仍能工作。',
'settings.general.networkTitle': '網路',
'settings.general.networkDescription': '控制桌面會話發起的服務商 API 請求。',
'settings.general.networkDescription': '控制桌面 AI 會話的對外網路包括模型服務、登入驗證、MCP 和 Agent 工具。',
'settings.general.networkProxyModeDirect': '直連',
'settings.general.networkProxyModeDirectDescription': '服務商 API 請求不使用應用程序繼承到的代理。',
'settings.general.networkProxyModeDirectDescription': '明確略過系統代理和程序繼承的代理。',
'settings.general.networkProxyModeSystem': '系統代理',
'settings.general.networkProxyModeSystemDescription': '使用應用程序繼承到的代理設定。',
'settings.general.networkProxyModeSystemDescription': '依目標位址動態遵循系統代理或 PAC 規則,系統變更會自動生效;需要代理帳號密碼時請使用手動代理。',
'settings.general.networkProxyModeManual': '手動代理',
'settings.general.networkProxyModeManualDescription': '使用下方填寫的 HTTP 或 HTTPS 代理地址。',
'settings.general.networkProxyModeManualDescription': '讓會話流量使用下方填寫的 HTTP 或 HTTPS 代理位址。',
'settings.general.networkProxyUrl': '代理地址',
'settings.general.networkProxyUrlHint': '支援 HTTP 和 HTTPS 代理,例如 http://127.0.0.1:7890。',
'settings.general.networkProxyUrlInvalid': '請輸入 HTTP 或 HTTPS 代理地址。',
@ -1231,7 +1231,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.networkTimeoutRequired': '請輸入超時時間。',
'settings.general.networkTimeoutRange': '請輸入 {min}-{max} 秒之間的整數。',
'settings.general.networkSaved': '網路設定已儲存。',
'settings.general.networkScopeHint': '不會影響單獨的應用更新代理。',
'settings.general.networkScopeHint': '對新請求生效;進行中的請求維持原路由。應用程式更新使用獨立的代理設定。',
'settings.general.networkSave': '儲存',
'settings.general.webFetchPreflightTitle': 'WebFetch 預檢',
'settings.general.webFetchPreflightDescription': '桌面端預設跳過 Claude 的域名預檢,避免第三方服務商或受限網路下出現誤報失敗。',

View File

@ -1211,13 +1211,13 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessStaleHostApply': '切到 {suggestedHost}',
'settings.general.h5AccessProxyNote': '当前使用的是反向代理 URL手机扫码会经公网/隧道返回桌面端。请确认你的反代仍能工作。',
'settings.general.networkTitle': '网络',
'settings.general.networkDescription': '控制桌面会话发起的服务商 API 请求。',
'settings.general.networkDescription': '控制桌面 AI 会话的出网行为包括模型服务、登录鉴权、MCP 和 Agent 工具。',
'settings.general.networkProxyModeDirect': '直连',
'settings.general.networkProxyModeDirectDescription': '服务商 API 请求不使用应用进程继承到的代理。',
'settings.general.networkProxyModeDirectDescription': '明确绕过系统代理和进程继承的代理。',
'settings.general.networkProxyModeSystem': '系统代理',
'settings.general.networkProxyModeSystemDescription': '使用应用进程继承到的代理设置。',
'settings.general.networkProxyModeSystemDescription': '按目标地址动态遵循系统代理或 PAC 规则,系统变化会自动生效;需要代理账号密码时请用手动代理。',
'settings.general.networkProxyModeManual': '手动代理',
'settings.general.networkProxyModeManualDescription': '使用下方填写的 HTTP 或 HTTPS 代理地址。',
'settings.general.networkProxyModeManualDescription': '让会话流量使用下方填写的 HTTP 或 HTTPS 代理地址。',
'settings.general.networkProxyUrl': '代理地址',
'settings.general.networkProxyUrlHint': '支持 HTTP 和 HTTPS 代理。需要认证时可填写 http://user:password@127.0.0.1:7890该 URL 会随网络设置保存。',
'settings.general.networkProxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。',
@ -1231,7 +1231,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.networkTimeoutRequired': '请输入超时时间。',
'settings.general.networkTimeoutRange': '请输入 {min}-{max} 秒之间的整数。',
'settings.general.networkSaved': '网络设置已保存。',
'settings.general.networkScopeHint': '不会影响单独的应用更新代理。',
'settings.general.networkScopeHint': '对新请求生效;已在进行中的请求保持原路由。应用更新使用独立的代理设置。',
'settings.general.networkSave': '保存',
'settings.general.webFetchPreflightTitle': 'WebFetch 预检',
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',

View File

@ -201,7 +201,7 @@ describe('settingsStore network persistence', () => {
window.localStorage.clear()
})
it('defaults old user settings to 600s direct network settings', async () => {
it('defaults old user settings to 600s system network settings', async () => {
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn().mockResolvedValue({}),
@ -244,12 +244,70 @@ describe('settingsStore network persistence', () => {
expect(useSettingsStore.getState().network).toEqual({
aiRequestTimeoutMs: 600_000,
proxy: {
mode: 'direct',
mode: 'system',
url: '',
},
})
})
it('persists explicit system network mode without a stale manual URL', async () => {
const updateUser = vi.fn().mockResolvedValue({})
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn(),
updateUser,
getPermissionMode: vi.fn(),
setPermissionMode: vi.fn(),
getCliLauncherStatus: vi.fn(),
},
}))
vi.doMock('../api/models', () => ({
modelsApi: {
list: vi.fn(),
getCurrent: vi.fn(),
setCurrent: vi.fn(),
getEffort: vi.fn(),
setEffort: vi.fn(),
},
}))
vi.doMock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().setNetwork({
aiRequestTimeoutMs: 600_000,
proxy: {
mode: 'system',
url: ' http://stale.example:8080 ',
},
})
expect(useSettingsStore.getState().network).toEqual({
aiRequestTimeoutMs: 600_000,
proxy: {
mode: 'system',
url: '',
},
})
expect(updateUser).toHaveBeenCalledWith({
network: {
aiRequestTimeoutMs: 600_000,
proxy: {
mode: 'system',
url: '',
},
},
})
})
it('persists direct network proxy mode without keeping stale proxy URLs active', async () => {
const updateUser = vi.fn().mockResolvedValue({})
vi.doMock('../api/settings', () => ({

View File

@ -152,7 +152,7 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = {
const DEFAULT_NETWORK_SETTINGS: NetworkSettings = {
aiRequestTimeoutMs: 600_000,
proxy: {
mode: 'direct',
mode: 'system',
url: '',
},
}
@ -664,9 +664,9 @@ function normalizeNetworkSettings(
: DEFAULT_NETWORK_SETTINGS.aiRequestTimeoutMs
const proxyMode = settings?.proxy?.mode === 'manual'
? 'manual'
: settings?.proxy?.mode === 'system'
? 'system'
: 'direct'
: settings?.proxy?.mode === 'direct'
? 'direct'
: 'system'
return {
aiRequestTimeoutMs: timeout,

View File

@ -4,9 +4,27 @@ import {
clearOAuthTokenCache,
getClaudeAIOAuthTokens,
} from '../../utils/auth.js'
import {
clearProxyCache,
configureGlobalAgents,
getProxyFetchOptions,
getProxyUrl,
} from '../../utils/proxy.js'
describe('StructuredIO environment updates', () => {
const originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
const originalNetworkEnv = Object.fromEntries(
[
'HTTP_PROXY',
'HTTPS_PROXY',
'http_proxy',
'https_proxy',
'ALL_PROXY',
'all_proxy',
'NO_PROXY',
'no_proxy',
].map(key => [key, process.env[key]]),
)
afterEach(() => {
if (originalOAuthToken === undefined) {
@ -14,6 +32,12 @@ describe('StructuredIO environment updates', () => {
} else {
process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken
}
for (const [key, value] of Object.entries(originalNetworkEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
clearProxyCache()
configureGlobalAgents()
clearOAuthTokenCache()
})
@ -37,4 +61,32 @@ describe('StructuredIO environment updates', () => {
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('fresh-env-token')
expect(getClaudeAIOAuthTokens()?.accessToken).toBe('fresh-env-token')
})
test('reconfigures request routing when proxy variables change at runtime', async () => {
const proxyUrl = 'http://127.0.0.1:17890'
async function* input() {
yield `${JSON.stringify({
type: 'update_environment_variables',
variables: {
HTTP_PROXY: proxyUrl,
HTTPS_PROXY: proxyUrl,
http_proxy: proxyUrl,
https_proxy: proxyUrl,
ALL_PROXY: proxyUrl,
all_proxy: proxyUrl,
NO_PROXY: 'localhost,127.0.0.1,::1',
no_proxy: 'localhost,127.0.0.1,::1',
},
})}\n`
}
const io = new StructuredIO(input())
for await (const _message of io.structuredInput) {
// update_environment_variables messages are consumed internally.
}
expect(getProxyUrl()).toBe(proxyUrl)
expect(getProxyFetchOptions({ targetUrl: 'https://api.openai.com' }).proxy).toBe(proxyUrl)
expect(getProxyFetchOptions({ targetUrl: 'http://127.0.0.1:3456' }).proxy).toBeUndefined()
})
})

View File

@ -61,6 +61,13 @@ import { ndjsonSafeStringify } from './ndjsonSafeStringify.js'
*/
export const SANDBOX_NETWORK_ACCESS_TOOL_NAME = 'SandboxNetworkAccess'
const NETWORK_RUNTIME_ENV_KEYS = new Set([
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
])
function serializeDecisionReason(
reason: PermissionDecisionReason | undefined,
): string | undefined {
@ -354,6 +361,13 @@ export class StructuredIO {
for (const [key, value] of Object.entries(message.variables)) {
process.env[key] = value
}
if (keys.some(key => NETWORK_RUNTIME_ENV_KEYS.has(key.toUpperCase()))) {
const { clearProxyCache, configureGlobalAgents } = await import(
'../utils/proxy.js'
)
clearProxyCache()
configureGlobalAgents()
}
if (Object.hasOwn(message.variables, 'CLAUDE_CODE_OAUTH_TOKEN')) {
const { clearOAuthTokenCache } = await import('../utils/auth.js')
clearOAuthTokenCache()

View File

@ -179,6 +179,30 @@ describe('ConversationService', () => {
)
}
function installNetworkTestSession(
service: any,
sessionId: string,
sent: string[],
networkDerivedFirstTokenTimeout = true,
) {
const session = {
outputCallbacks: [],
networkRoutingFingerprint: '',
networkDerivedFirstTokenTimeout,
sdkSocket: {
send(line: string) {
sent.push(line)
},
},
pendingOutbound: [],
usesOfficialOAuth: false,
officialOAuthToken: null,
pendingPermissionRequests: new Map(),
}
service.sessions.set(sessionId, session)
return session
}
test('keeps inherited provider env when no desktop provider config exists', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
@ -326,10 +350,39 @@ 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.ALL_PROXY).toBe('http://127.0.0.1:7890')
expect(env.all_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 routes system mode through the host-managed dynamic bridge', async () => {
const originalBridgeUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:17890'
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
proxy: { mode: 'system', url: '' },
},
}),
'utf-8',
)
try {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:17890')
expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:17890')
expect(env.ALL_PROXY).toBe('http://127.0.0.1:17890')
expect(env.all_proxy).toBe('http://127.0.0.1:17890')
} finally {
if (originalBridgeUrl === undefined) delete process.env.CC_HAHA_SYSTEM_PROXY_URL
else process.env.CC_HAHA_SYSTEM_PROXY_URL = originalBridgeUrl
}
})
test('buildChildEnv ties the first-token watchdog to the user request timeout so slow prefill is not killed early (#826)', async () => {
const prev = process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
delete process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
@ -438,6 +491,117 @@ describe('ConversationService', () => {
expect(JSON.parse(sent[1]!).type).toBe('user')
})
test('sendMessage hot-applies direct to system routing before the next user turn', async () => {
const originalBridgeUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:17890'
try {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ network: { proxy: { mode: 'direct', url: '' } } }),
'utf-8',
)
const service = new ConversationService() as any
const sent: string[] = []
const session = installNetworkTestSession(service, 'direct-to-system', sent)
await service.refreshNetworkEnvironmentBeforeTurn('direct-to-system', session)
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ network: { proxy: { mode: 'system', url: '' } } }),
'utf-8',
)
expect(await service.sendMessage('direct-to-system', 'use system proxy')).toBe(true)
expect(sent).toHaveLength(2)
const update = JSON.parse(sent[0]!)
expect(update.type).toBe('update_environment_variables')
expect(update.variables).toMatchObject({
HTTP_PROXY: 'http://127.0.0.1:17890',
HTTPS_PROXY: 'http://127.0.0.1:17890',
http_proxy: 'http://127.0.0.1:17890',
https_proxy: 'http://127.0.0.1:17890',
ALL_PROXY: 'http://127.0.0.1:17890',
all_proxy: 'http://127.0.0.1:17890',
API_TIMEOUT_MS: '600000',
CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS: '600000',
})
expect(update.variables.NO_PROXY).toContain('127.0.0.1')
expect(update.variables.no_proxy).toContain('localhost')
expect(JSON.parse(sent[1]!).type).toBe('user')
} finally {
if (originalBridgeUrl === undefined) delete process.env.CC_HAHA_SYSTEM_PROXY_URL
else process.env.CC_HAHA_SYSTEM_PROXY_URL = originalBridgeUrl
}
})
test('sendMessage hot-applies manual proxy and timeout changes before the next user turn', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 600_000,
proxy: { mode: 'manual', url: 'http://127.0.0.1:17891' },
},
}),
'utf-8',
)
const service = new ConversationService() as any
const sent: string[] = []
const session = installNetworkTestSession(service, 'manual-change', sent)
await service.refreshNetworkEnvironmentBeforeTurn('manual-change', session)
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 180_000,
proxy: { mode: 'manual', url: 'http://127.0.0.1:17892' },
},
}),
'utf-8',
)
expect(await service.sendMessage('manual-change', 'use changed proxy')).toBe(true)
expect(sent).toHaveLength(2)
const update = JSON.parse(sent[0]!)
expect(update.type).toBe('update_environment_variables')
expect(update.variables).toMatchObject({
HTTP_PROXY: 'http://127.0.0.1:17892',
HTTPS_PROXY: 'http://127.0.0.1:17892',
ALL_PROXY: 'http://127.0.0.1:17892',
all_proxy: 'http://127.0.0.1:17892',
API_TIMEOUT_MS: '180000',
CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS: '180000',
})
expect(JSON.parse(sent[1]!).type).toBe('user')
})
test('sendMessage does not resend network env when the system bridge fingerprint is unchanged', async () => {
const originalBridgeUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:17893'
try {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ network: { proxy: { mode: 'system', url: '' } } }),
'utf-8',
)
const service = new ConversationService() as any
const sent: string[] = []
const session = installNetworkTestSession(service, 'unchanged-system', sent)
await service.refreshNetworkEnvironmentBeforeTurn('unchanged-system', session)
// PAC/system rules are resolved dynamically inside this stable bridge URL.
// Their changes must not churn the CLI environment between turns.
expect(await service.sendMessage('unchanged-system', 'same bridge')).toBe(true)
expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0]!).type).toBe('user')
} finally {
if (originalBridgeUrl === undefined) delete process.env.CC_HAHA_SYSTEM_PROXY_URL
else process.env.CC_HAHA_SYSTEM_PROXY_URL = originalBridgeUrl
}
})
test('buildChildEnv does NOT inject CLAUDE_CODE_OAUTH_TOKEN when not official mode', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })

View File

@ -13,6 +13,7 @@ import {
import { CronService } from '../services/cronService.js'
import { ProviderService } from '../services/providerService.js'
import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalPath = process.env.PATH
@ -27,6 +28,11 @@ const originalZdotdir = process.env.ZDOTDIR
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
const originalTaskTimeout = process.env.CC_HAHA_TASK_TIMEOUT_MS
const originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
const originalSystemProxyUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
const originalHttpProxy = process.env.HTTP_PROXY
const originalHttpsProxy = process.env.HTTPS_PROXY
const originalLowerHttpProxy = process.env.http_proxy
const originalLowerHttpsProxy = process.env.https_proxy
const isWindows = process.platform === 'win32'
const unixOnly = isWindows ? it.skip : it
@ -120,6 +126,20 @@ function restoreEnv(): void {
} else {
delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
}
if (originalSystemProxyUrl !== undefined) {
process.env.CC_HAHA_SYSTEM_PROXY_URL = originalSystemProxyUrl
} else {
delete process.env.CC_HAHA_SYSTEM_PROXY_URL
}
if (originalHttpProxy !== undefined) process.env.HTTP_PROXY = originalHttpProxy
else delete process.env.HTTP_PROXY
if (originalHttpsProxy !== undefined) process.env.HTTPS_PROXY = originalHttpsProxy
else delete process.env.HTTPS_PROXY
if (originalLowerHttpProxy !== undefined) process.env.http_proxy = originalLowerHttpProxy
else delete process.env.http_proxy
if (originalLowerHttpsProxy !== undefined) process.env.https_proxy = originalLowerHttpsProxy
else delete process.env.https_proxy
resetSettingsCache()
resetTerminalShellEnvironmentCacheForTests()
}
@ -130,6 +150,7 @@ describe('cron scheduler launcher resolution', () => {
tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, 'config')
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
resetSettingsCache()
resetTerminalShellEnvironmentCacheForTests()
})
@ -417,6 +438,83 @@ describe('cron scheduler launcher resolution', () => {
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli')
})
unixOnly('executeTask applies direct, system, and manual network settings to the sidecar', async () => {
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const sidecarEnvPath = path.join(tmpDir, 'sidecar.env')
const settingsPath = path.join(process.env.CLAUDE_CONFIG_DIR!, 'settings.json')
await fs.mkdir(appRoot, { recursive: true })
await fs.mkdir(path.dirname(settingsPath), { recursive: true })
await fs.writeFile(
sidecarPath,
[
'#!/bin/sh',
`env | sort > "${sidecarEnvPath}"`,
'/bin/cat >/dev/null',
'printf \'%s\\n\' \'{"type":"result","result":"network env ok"}\'',
'exit 0',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(sidecarPath, 0o755)
process.env.CLAUDE_CLI_PATH = sidecarPath
process.env.CLAUDE_APP_ROOT = appRoot
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:7897'
process.env.HTTP_PROXY = 'http://stale-parent.example:8080'
process.env.HTTPS_PROXY = 'http://stale-parent.example:8080'
process.env.http_proxy = 'http://stale-parent.example:8080'
process.env.https_proxy = 'http://stale-parent.example:8080'
const cronService = new CronService()
const scheduler = new CronScheduler(cronService)
const task = await cronService.createTask({
cron: '* * * * *',
prompt: 'cron network env test',
name: 'Network Env Task',
recurring: true,
folderPath: tmpDir,
})
const cases = [
{ mode: 'direct', url: '', expectedProxyUrl: '' },
{ mode: 'system', url: '', expectedProxyUrl: 'http://127.0.0.1:7897' },
{ mode: 'manual', url: ' http://127.0.0.1:7890 ', expectedProxyUrl: 'http://127.0.0.1:7890' },
] as const
for (const testCase of cases) {
await fs.writeFile(
settingsPath,
JSON.stringify({
network: {
proxy: { mode: testCase.mode, url: testCase.url },
},
}),
'utf-8',
)
resetSettingsCache()
const run = await scheduler.executeTask(task)
expect(run.status).toBe('completed')
expect(run.output).toBe('network env ok')
const env = Object.fromEntries(
(await fs.readFile(sidecarEnvPath, 'utf-8'))
.trim()
.split('\n')
.map((line) => {
const index = line.indexOf('=')
return [line.slice(0, index), line.slice(index + 1)]
}),
)
expect(env.HTTP_PROXY).toBe(testCase.expectedProxyUrl)
expect(env.HTTPS_PROXY).toBe(testCase.expectedProxyUrl)
expect(env.http_proxy).toBe(testCase.expectedProxyUrl)
expect(env.https_proxy).toBe(testCase.expectedProxyUrl)
}
})
unixOnly('executeTask launches scheduled tasks with full permissions', async () => {
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')

View File

@ -6,16 +6,28 @@ import {
HahaGrokOAuthService,
getHahaGrokOAuthFilePath,
} from '../services/hahaGrokOAuthService.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
let tempDir: string
let previousConfigDir: string | undefined
let previousFetch: typeof globalThis.fetch
let service: HahaGrokOAuthService
const originalSystemProxyUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
const originalHttpProxy = process.env.HTTP_PROXY
const originalHttpsProxy = process.env.HTTPS_PROXY
const originalLowerHttpProxy = process.env.http_proxy
const originalLowerHttpsProxy = process.env.https_proxy
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-grok-oauth-test-'))
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tempDir
resetSettingsCache()
previousFetch = globalThis.fetch
service = new HahaGrokOAuthService()
})
@ -25,6 +37,12 @@ afterEach(async () => {
globalThis.fetch = previousFetch
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
restoreEnv('CC_HAHA_SYSTEM_PROXY_URL', originalSystemProxyUrl)
restoreEnv('HTTP_PROXY', originalHttpProxy)
restoreEnv('HTTPS_PROXY', originalHttpsProxy)
restoreEnv('http_proxy', originalLowerHttpProxy)
restoreEnv('https_proxy', originalLowerHttpsProxy)
resetSettingsCache()
await fs.rm(tempDir, { recursive: true, force: true })
})
@ -84,4 +102,51 @@ describe('HahaGrokOAuthService', () => {
refreshToken: 'old-refresh',
})
})
test('uses direct, system, and manual network settings for token refresh', async () => {
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:7897'
process.env.HTTP_PROXY = 'http://stale-parent.example:8080'
process.env.HTTPS_PROXY = 'http://stale-parent.example:8080'
process.env.http_proxy = 'http://stale-parent.example:8080'
process.env.https_proxy = 'http://stale-parent.example:8080'
const cases = [
{ mode: 'direct', url: '', expectedProxyUrl: null },
{ mode: 'system', url: '', expectedProxyUrl: 'http://127.0.0.1:7897' },
{ mode: 'manual', url: ' http://127.0.0.1:7890 ', expectedProxyUrl: 'http://127.0.0.1:7890' },
] as const
for (const testCase of cases) {
await fs.writeFile(
path.join(tempDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 45_000,
proxy: { mode: testCase.mode, url: testCase.url },
},
}),
'utf-8',
)
resetSettingsCache()
await service.saveTokens({
accessToken: 'old-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() - 1,
email: 'user@example.com',
})
service.setRefreshFn(async (_refreshToken, options) => {
expect(options?.proxyUrl).toBe(testCase.expectedProxyUrl)
expect(options?.timeoutMs).toBe(45_000)
return {
access_token: 'new-access',
expires_in: 3600,
}
})
await expect(service.ensureFreshTokens()).resolves.toMatchObject({
accessToken: 'new-access',
})
}
})
})

View File

@ -10,14 +10,20 @@ import {
HahaOAuthService,
type StoredOAuthTokens,
} from '../services/hahaOAuthService.js'
import { SYSTEM_PROXY_URL_ENV } from '../services/networkSettings.js'
let tmpDir: string
let originalConfigDir: string | undefined
let originalSystemProxyUrl: string | undefined
let originalFetch: typeof globalThis.fetch
let service: HahaOAuthService
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-oauth-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalSystemProxyUrl = process.env[SYSTEM_PROXY_URL_ENV]
originalFetch = globalThis.fetch
delete process.env[SYSTEM_PROXY_URL_ENV]
process.env.CLAUDE_CONFIG_DIR = tmpDir
service = new HahaOAuthService()
}
@ -28,6 +34,12 @@ async function teardown() {
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
if (originalSystemProxyUrl === undefined) {
delete process.env[SYSTEM_PROXY_URL_ENV]
} else {
process.env[SYSTEM_PROXY_URL_ENV] = originalSystemProxyUrl
}
globalThis.fetch = originalFetch
await fs.rm(tmpDir, { recursive: true, force: true })
}
@ -105,6 +117,7 @@ describe('HahaOAuthService — session management', () => {
})
test('completeSession stores subscription type fetched from profile info', async () => {
process.env[SYSTEM_PROXY_URL_ENV] = 'http://127.0.0.1:17890'
const session = service.startSession({ serverPort: 54321 })
;(service as any).exchangeWithCustomCallback = async () => ({
access_token: 'fresh-access-token',
@ -112,15 +125,42 @@ describe('HahaOAuthService — session management', () => {
expires_in: 3600,
scope: 'user:inference',
})
service.setFetchProfileFn(async () => ({
subscriptionType: 'team',
}))
let profileProxyUrl: string | null | undefined
service.setFetchProfileFn(async (_accessToken, options) => {
profileProxyUrl = options?.proxyUrl
return { subscriptionType: 'team' }
})
const tokens = await service.completeSession('authorization-code', session.state)
expect(tokens.subscriptionType).toBe('team')
expect(profileProxyUrl).toBe('http://127.0.0.1:17890')
expect((await service.loadTokens())?.subscriptionType).toBe('team')
})
test('routes the Claude token exchange through the dynamic system proxy bridge', async () => {
const bridgeUrl = 'http://127.0.0.1:17890'
process.env[SYSTEM_PROXY_URL_ENV] = bridgeUrl
let requestProxy: string | undefined
globalThis.fetch = (async (_input, init) => {
requestProxy = (init as RequestInit & { proxy?: string } | undefined)?.proxy
return Response.json({
access_token: 'access',
refresh_token: 'refresh',
expires_in: 3600,
scope: 'user:inference',
})
}) as typeof fetch
await (service as any).exchangeWithCustomCallback(
'authorization-code',
'state',
'verifier',
54321,
)
expect(requestProxy).toBe(bridgeUrl)
})
})
describe('HahaOAuthService — ensureFreshAccessToken', () => {
@ -145,6 +185,7 @@ describe('HahaOAuthService — ensureFreshAccessToken', () => {
})
test('refreshes token when expired (within 5-min buffer)', async () => {
process.env[SYSTEM_PROXY_URL_ENV] = 'http://127.0.0.1:17890'
const oldTokens: StoredOAuthTokens = {
accessToken: 'expired',
refreshToken: 'refresh-xxx',
@ -154,17 +195,22 @@ describe('HahaOAuthService — ensureFreshAccessToken', () => {
}
await service.saveTokens(oldTokens)
service.setRefreshFn(async () => ({
accessToken: 'new-fresh-token',
refreshToken: 'new-refresh-xxx',
expiresAt: Date.now() + 3600_000,
scopes: ['user:inference'],
subscriptionType: 'max',
rateLimitTier: null,
}))
let refreshProxyUrl: string | null | undefined
service.setRefreshFn(async (_refreshToken, options) => {
refreshProxyUrl = options?.proxyUrl
return {
accessToken: 'new-fresh-token',
refreshToken: 'new-refresh-xxx',
expiresAt: Date.now() + 3600_000,
scopes: ['user:inference'],
subscriptionType: 'max',
rateLimitTier: null,
}
})
const fresh = await service.ensureFreshAccessToken()
expect(fresh).toBe('new-fresh-token')
expect(refreshProxyUrl).toBe('http://127.0.0.1:17890')
const loaded = await service.loadTokens()
expect(loaded?.accessToken).toBe('new-fresh-token')

View File

@ -18,6 +18,11 @@ let tmpDir: string
let originalConfigDir: string | undefined
let service: HahaOpenAIOAuthService
let callbackPort: number
const originalSystemProxyUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL
const originalHttpProxy = process.env.HTTP_PROXY
const originalHttpsProxy = process.env.HTTPS_PROXY
const originalLowerHttpProxy = process.env.http_proxy
const originalLowerHttpsProxy = process.env.https_proxy
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
@ -88,10 +93,20 @@ async function teardown() {
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
restoreEnv('CC_HAHA_SYSTEM_PROXY_URL', originalSystemProxyUrl)
restoreEnv('HTTP_PROXY', originalHttpProxy)
restoreEnv('HTTPS_PROXY', originalHttpsProxy)
restoreEnv('http_proxy', originalLowerHttpProxy)
restoreEnv('https_proxy', originalLowerHttpsProxy)
resetSettingsCache()
await fs.rm(tmpDir, { recursive: true, force: true })
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
describe('HahaOpenAIOAuthService — file storage', () => {
beforeEach(setup)
afterEach(teardown)
@ -300,6 +315,92 @@ describe('HahaOpenAIOAuthService — session management', () => {
}
})
test('callback listener uses the captured system proxy instead of inherited proxy env', async () => {
const originalFetch = globalThis.fetch
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:7897'
process.env.HTTP_PROXY = 'http://stale-parent.example:8080'
process.env.HTTPS_PROXY = 'http://stale-parent.example:8080'
process.env.http_proxy = 'http://stale-parent.example:8080'
process.env.https_proxy = 'http://stale-parent.example:8080'
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 45_000,
proxy: { mode: 'system', url: '' },
},
}),
'utf-8',
)
resetSettingsCache()
const session = await service.startSession({ serverPort: 54321 })
let tokenRequestInit: RequestInit | undefined
globalThis.fetch = (async (_url, init) => {
tokenRequestInit = init
return Response.json({
access_token: 'openai-access-token',
refresh_token: 'openai-refresh-token',
expires_in: 3600,
})
}) as typeof fetch
try {
const res = await getLocalCallback(
`/auth/callback?code=auth-code&state=${session.state}`,
)
expect(res.status).toBe(200)
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
'http://127.0.0.1:7897',
)
} finally {
globalThis.fetch = originalFetch
}
})
test('callback listener disables inherited proxy env in direct mode', async () => {
const originalFetch = globalThis.fetch
process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:7897'
process.env.HTTP_PROXY = 'http://stale-parent.example:8080'
process.env.HTTPS_PROXY = 'http://stale-parent.example:8080'
process.env.http_proxy = 'http://stale-parent.example:8080'
process.env.https_proxy = 'http://stale-parent.example:8080'
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 45_000,
proxy: { mode: 'direct', url: '' },
},
}),
'utf-8',
)
resetSettingsCache()
const session = await service.startSession({ serverPort: 54321 })
let tokenRequestInit: RequestInit | undefined
globalThis.fetch = (async (_url, init) => {
tokenRequestInit = init
return Response.json({
access_token: 'openai-access-token',
refresh_token: 'openai-refresh-token',
expires_in: 3600,
})
}) as typeof fetch
try {
const res = await getLocalCallback(
`/auth/callback?code=auth-code&state=${session.state}`,
)
expect(res.status).toBe(200)
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBeUndefined()
} finally {
globalThis.fetch = originalFetch
}
})
test('callback listener renders an error page when token exchange fails', async () => {
const originalFetch = globalThis.fetch
const session = await service.startSession({ serverPort: 54321 })

View File

@ -6,9 +6,12 @@ import {
DEFAULT_AI_REQUEST_TIMEOUT_MS,
MAX_AI_REQUEST_TIMEOUT_MS,
MIN_AI_REQUEST_TIMEOUT_MS,
SYSTEM_PROXY_ERROR_ENV,
SYSTEM_PROXY_URL_ENV,
getManualNetworkProxyUrl,
buildNetworkEnvironment,
getNetworkProxyFetchOptions,
getNetworkProxyUrl,
loadNetworkSettings,
normalizeNetworkSettings,
} from '../services/networkSettings.js'
@ -16,10 +19,28 @@ import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
let tmpDir: string
let originalConfigDir: string | undefined
const PROXY_ENV_KEYS = [
'HTTP_PROXY',
'HTTPS_PROXY',
'http_proxy',
'https_proxy',
'ALL_PROXY',
'all_proxy',
'NO_PROXY',
'no_proxy',
SYSTEM_PROXY_URL_ENV,
SYSTEM_PROXY_ERROR_ENV,
] as const
let originalProxyEnv: Partial<Record<typeof PROXY_ENV_KEYS[number], string>>
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'network-settings-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalProxyEnv = {}
for (const key of PROXY_ENV_KEYS) {
if (process.env[key] !== undefined) originalProxyEnv[key] = process.env[key]
delete process.env[key]
}
process.env.CLAUDE_CONFIG_DIR = tmpDir
resetSettingsCache()
}
@ -30,6 +51,11 @@ async function teardown() {
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
for (const key of PROXY_ENV_KEYS) {
const originalValue = originalProxyEnv[key]
if (originalValue === undefined) delete process.env[key]
else process.env[key] = originalValue
}
resetSettingsCache()
await fs.rm(tmpDir, { recursive: true, force: true })
}
@ -38,17 +64,23 @@ describe('network settings', () => {
beforeEach(setup)
afterEach(teardown)
it('normalizes missing settings to the 600s direct-proxy default', () => {
it('normalizes missing and legacy-invalid settings to the 600s system-proxy default', () => {
expect(normalizeNetworkSettings({})).toEqual({
aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS,
proxy: {
mode: 'direct',
mode: 'system',
url: '',
},
})
expect(normalizeNetworkSettings({
network: { proxy: { mode: 'legacy', url: 'http://stale.example:8080' } },
}).proxy).toEqual({
mode: 'system',
url: '',
})
})
it('clears inherited proxy environment for direct provider requests', () => {
it('preserves explicit direct mode and clears inherited HTTP, HTTPS, and ALL proxy environment', () => {
const settings = normalizeNetworkSettings({
network: {
proxy: {
@ -63,16 +95,24 @@ describe('network settings', () => {
HTTPS_PROXY: 'http://127.0.0.1:1181',
http_proxy: 'http://127.0.0.1:1181',
https_proxy: 'http://127.0.0.1:1181',
ALL_PROXY: 'socks5://127.0.0.1:1182',
all_proxy: 'socks5://127.0.0.1:1182',
})).toMatchObject({
HTTP_PROXY: '',
HTTPS_PROXY: '',
http_proxy: '',
https_proxy: '',
ALL_PROXY: '',
all_proxy: '',
})
expect(getNetworkProxyUrl(settings, {
[SYSTEM_PROXY_URL_ENV]: 'http://127.0.0.1:1183',
})).toBeNull()
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
expect(getNetworkProxyFetchOptions(settings, 'https://api.example.com/v1/messages').proxy).toBeUndefined()
})
it('keeps inherited process proxy for explicit system provider requests', () => {
it('uses only the explicit system-proxy bridge for system provider requests', () => {
const settings = normalizeNetworkSettings({
network: {
proxy: {
@ -82,30 +122,70 @@ describe('network settings', () => {
},
})
const originalHttpProxy = process.env.HTTP_PROXY
const originalHttpsProxy = process.env.HTTPS_PROXY
const originalLowerHttpProxy = process.env.http_proxy
const originalLowerHttpsProxy = process.env.https_proxy
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
delete process.env.http_proxy
delete process.env.https_proxy
try {
expect(buildNetworkEnvironment(settings)).toEqual({
API_TIMEOUT_MS: String(DEFAULT_AI_REQUEST_TIMEOUT_MS),
})
expect(getNetworkProxyFetchOptions(settings, 'https://api.example.com/v1/messages').proxy)
.toBe('http://127.0.0.1:1181')
} finally {
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 (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
const bridgeUrl = 'http://127.0.0.1:1183'
const baseEnv = {
HTTP_PROXY: 'http://inherited.example:8080',
ALL_PROXY: 'socks5://inherited.example:1080',
NO_PROXY: '.corp.local',
[SYSTEM_PROXY_URL_ENV]: ` ${bridgeUrl} `,
}
expect(getNetworkProxyUrl(settings, baseEnv)).toBe(bridgeUrl)
expect(buildNetworkEnvironment(settings, baseEnv)).toEqual({
API_TIMEOUT_MS: String(DEFAULT_AI_REQUEST_TIMEOUT_MS),
HTTP_PROXY: bridgeUrl,
HTTPS_PROXY: bridgeUrl,
http_proxy: bridgeUrl,
https_proxy: bridgeUrl,
ALL_PROXY: bridgeUrl,
all_proxy: bridgeUrl,
NO_PROXY: '.corp.local,localhost,127.0.0.1,::1',
no_proxy: '.corp.local,localhost,127.0.0.1,::1',
})
process.env.HTTP_PROXY = 'http://inherited.example:8080'
process.env[SYSTEM_PROXY_URL_ENV] = bridgeUrl
expect(getNetworkProxyFetchOptions(settings, 'https://api.example.com/v1/messages').proxy)
.toBe(bridgeUrl)
})
it('uses inherited process proxy for non-Electron system-mode servers', () => {
const settings = normalizeNetworkSettings({
network: { proxy: { mode: 'system', url: '' } },
})
process.env.HTTP_PROXY = 'http://inherited.example:8080'
expect(getNetworkProxyUrl(settings)).toBe('http://inherited.example:8080')
expect(buildNetworkEnvironment(settings, process.env)).toMatchObject({
HTTP_PROXY: 'http://inherited.example:8080',
HTTPS_PROXY: 'http://inherited.example:8080',
http_proxy: 'http://inherited.example:8080',
https_proxy: 'http://inherited.example:8080',
ALL_PROXY: 'http://inherited.example:8080',
all_proxy: 'http://inherited.example:8080',
})
expect(getNetworkProxyFetchOptions(settings, 'https://api.example.com/v1/messages').proxy)
.toBe('http://inherited.example:8080')
})
it('surfaces a host bridge startup failure instead of silently going direct', () => {
const settings = normalizeNetworkSettings({
network: { proxy: { mode: 'system', url: '' } },
})
const env = {
[SYSTEM_PROXY_ERROR_ENV]: 'local bridge could not bind',
}
expect(() => getNetworkProxyUrl(settings, env))
.toThrow('local bridge could not bind')
expect(() => buildNetworkEnvironment(settings, env))
.toThrow('local bridge could not bind')
expect(getNetworkProxyUrl(normalizeNetworkSettings({
network: { proxy: { mode: 'direct', url: '' } },
}), env)).toBeNull()
expect(getNetworkProxyUrl(normalizeNetworkSettings({
network: { proxy: { mode: 'manual', url: 'http://127.0.0.1:7890' } },
}), env)).toBe('http://127.0.0.1:7890')
})
it('clamps AI request timeouts and trims manual proxy URLs', () => {
@ -157,6 +237,8 @@ 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',
ALL_PROXY: 'http://127.0.0.1:7890',
all_proxy: 'http://127.0.0.1:7890',
NO_PROXY: 'localhost,127.0.0.1,::1',
no_proxy: 'localhost,127.0.0.1,::1',
})
@ -189,9 +271,37 @@ describe('network settings', () => {
})
expect(getManualNetworkProxyUrl(settings)).toBe('https://user:p%40ss@proxy.example.com:8443')
expect(getNetworkProxyUrl(settings, {
[SYSTEM_PROXY_URL_ENV]: 'http://system.example:8080',
})).toBe('https://user:p%40ss@proxy.example.com:8443')
expect(buildNetworkEnvironment(settings)).toMatchObject({
HTTP_PROXY: 'https://user:p%40ss@proxy.example.com:8443',
HTTPS_PROXY: 'https://user:p%40ss@proxy.example.com:8443',
ALL_PROXY: 'https://user:p%40ss@proxy.example.com:8443',
all_proxy: 'https://user:p%40ss@proxy.example.com:8443',
})
process.env.HTTP_PROXY = 'http://inherited.example:8080'
process.env[SYSTEM_PROXY_URL_ENV] = 'http://system.example:8080'
expect(getNetworkProxyFetchOptions(settings, 'https://api.example.com/v1/messages').proxy)
.toBe('https://user:p%40ss@proxy.example.com:8443')
})
it('fails closed to direct when a corrupted manual setting has no URL', () => {
const settings = normalizeNetworkSettings({
network: { proxy: { mode: 'manual', url: ' ' } },
})
expect(getNetworkProxyUrl(settings)).toBeNull()
expect(buildNetworkEnvironment(settings, {
HTTP_PROXY: 'http://inherited.example:8080',
ALL_PROXY: 'socks5://inherited.example:1080',
})).toMatchObject({
HTTP_PROXY: '',
HTTPS_PROXY: '',
http_proxy: '',
https_proxy: '',
ALL_PROXY: '',
all_proxy: '',
})
})
})

View File

@ -13,14 +13,17 @@ import {
} from '../services/titleService.js'
import { sessionService } from '../services/sessionService.js'
import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js'
import { SYSTEM_PROXY_URL_ENV } from '../services/networkSettings.js'
describe('titleService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
let originalSystemProxyUrl: string | undefined
let originalFetch: typeof globalThis.fetch
beforeEach(async () => {
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalSystemProxyUrl = process.env[SYSTEM_PROXY_URL_ENV]
originalFetch = globalThis.fetch
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'title-service-test-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
@ -30,6 +33,7 @@ describe('titleService', () => {
globalThis.fetch = originalFetch
hahaOpenAIOAuthService.dispose()
restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir)
restoreEnv(SYSTEM_PROXY_URL_ENV, originalSystemProxyUrl)
await fs.rm(tmpDir, { recursive: true, force: true })
})
@ -328,6 +332,17 @@ describe('titleService', () => {
})
test('generates titles when ChatGPT Official OAuth is active', async () => {
const systemProxyUrl = 'http://127.0.0.1:17890'
process.env[SYSTEM_PROXY_URL_ENV] = systemProxyUrl
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
proxy: { mode: 'system', url: '' },
},
}),
'utf-8',
)
const providerService = new ProviderService()
await providerService.activateProvider('openai-official')
await hahaOpenAIOAuthService.saveTokens({
@ -342,6 +357,7 @@ describe('titleService', () => {
url: string
headers: Record<string, string>
body: Record<string, unknown>
proxy?: string
}> = []
globalThis.fetch = (async (input, init) => {
const headers = new Headers(init?.headers)
@ -349,6 +365,7 @@ describe('titleService', () => {
url: String(input),
headers: Object.fromEntries(headers.entries()),
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
proxy: (init as RequestInit & { proxy?: string } | undefined)?.proxy,
})
return new Response([
'event: response.completed',
@ -365,6 +382,7 @@ describe('titleService', () => {
expect(upstreamCalls[0].headers.authorization).toBe('Bearer access-for-title')
expect(upstreamCalls[0].headers['chatgpt-account-id']).toBe('acct_title')
expect(upstreamCalls[0].body.stream).toBe(true)
expect(upstreamCalls[0].proxy).toBe(systemProxyUrl)
})
test('honors the configured provider auth strategy for title generation', async () => {

View File

@ -40,7 +40,12 @@ import { findCanonicalGitRoot } from '../../utils/git.js'
import { sanitizePath } from '../../utils/path.js'
import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js'
import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js'
import { buildNetworkEnvironment, loadNetworkSettings } from './networkSettings.js'
import {
buildNetworkEnvironment,
loadNetworkSettings,
SYSTEM_PROXY_URL_ENV,
type NetworkSettings,
} from './networkSettings.js'
import { readTraceCaptureSettings } from './traceCaptureService.js'
import { logError } from '../../utils/log.js'
import {
@ -102,11 +107,31 @@ type MaterializedAttachments = {
imageMetadataTexts: string[]
}
type SessionOutputCallback = (msg: any) => void
function networkRoutingFingerprint(
settings: NetworkSettings,
env: NodeJS.ProcessEnv = process.env,
): string {
return JSON.stringify({
timeoutMs: settings.aiRequestTimeoutMs,
proxyMode: settings.proxy.mode,
manualProxyUrl: settings.proxy.mode === 'manual' ? settings.proxy.url.trim() : '',
systemProxyUrl:
settings.proxy.mode === 'system'
? env[SYSTEM_PROXY_URL_ENV]?.trim() || ''
: '',
noProxy: env.no_proxy || env.NO_PROXY || '',
})
}
type SessionProcess = {
proc: ReturnType<typeof Bun.spawn>
outputCallbacks: Array<(msg: any) => void>
outputCallbacks: SessionOutputCallback[]
workDir: string
permissionMode: string
networkRoutingFingerprint: string
networkDerivedFirstTokenTimeout: boolean
sdkToken: string
sdkSocket: { send(data: string): void } | null
sdkAttached: Promise<void>
@ -324,7 +349,15 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
const childEnv = await this.buildChildEnv(launchWorkDir, sdkUrl, options)
const networkSettings = await loadNetworkSettings()
const networkRuntimeMetadata = { firstTokenTimeoutDerived: false }
const childEnv = await this.buildChildEnv(
launchWorkDir,
sdkUrl,
options,
networkSettings,
networkRuntimeMetadata,
)
const usesOfficialOAuth = this.shouldMarkManagedOAuth(options?.providerId)
let proc: ReturnType<typeof Bun.spawn>
@ -361,6 +394,8 @@ export class ConversationService {
outputCallbacks: [],
workDir: launchWorkDir,
permissionMode: options?.permissionMode || 'default',
networkRoutingFingerprint: networkRoutingFingerprint(networkSettings, childEnv),
networkDerivedFirstTokenTimeout: networkRuntimeMetadata.firstTokenTimeoutDerived,
sdkToken: this.getSdkTokenFromUrl(sdkUrl),
sdkSocket: null,
sdkAttached,
@ -491,11 +526,15 @@ export class ConversationService {
content: string,
attachments?: AttachmentRef[],
): Promise<boolean> {
const session = this.sessions.get(sessionId)
const userContent = await this.buildUserContent(content, sessionId, attachments)
let session = this.sessions.get(sessionId)
if (session && !await this.refreshNetworkEnvironmentBeforeTurn(sessionId, session)) {
return false
}
session = this.sessions.get(sessionId)
if (session) {
await this.refreshOfficialOAuthTokenBeforeTurn(sessionId, session)
}
const userContent = await this.buildUserContent(content, sessionId, attachments)
return this.sendSdkMessage(sessionId, {
type: 'user',
message: {
@ -507,6 +546,45 @@ export class ConversationService {
})
}
private async refreshNetworkEnvironmentBeforeTurn(
sessionId: string,
session: SessionProcess,
): Promise<boolean> {
const settings = await loadNetworkSettings()
const baseEnv = await getProcessEnvWithTerminalShellEnvironment()
const networkEnv = buildNetworkEnvironment(settings, baseEnv)
const fingerprint = networkRoutingFingerprint(settings, {
...baseEnv,
...networkEnv,
})
if (this.sessions.get(sessionId) !== session) return false
if (!session.networkRoutingFingerprint) {
session.networkRoutingFingerprint = fingerprint
return true
}
if (session.networkRoutingFingerprint === fingerprint) return true
const noProxy = networkEnv.no_proxy || networkEnv.NO_PROXY || ''
const variables: Record<string, string> = {
...networkEnv,
NO_PROXY: noProxy,
no_proxy: noProxy,
}
if (session.networkDerivedFirstTokenTimeout) {
variables.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS = networkEnv.API_TIMEOUT_MS
}
const sent = this.sendSdkMessage(sessionId, {
type: 'update_environment_variables',
variables,
})
if (sent && this.sessions.get(sessionId) === session) {
session.networkRoutingFingerprint = fingerprint
}
return sent
}
respondToPermission(
sessionId: string,
requestId: string,
@ -1143,6 +1221,8 @@ export class ConversationService {
workDir: string,
sdkUrl?: string,
options?: SessionStartOptions,
networkSettingsOverride?: NetworkSettings,
networkRuntimeMetadata?: { firstTokenTimeoutDerived: boolean },
): Promise<Record<string, string>> {
// Provider isolation: when Desktop has its own provider config/index,
// strip inherited provider env vars so the child CLI reads fresh values
@ -1176,6 +1256,10 @@ export class ConversationService {
] as const
const cleanEnv = await getProcessEnvWithTerminalShellEnvironment()
if (networkRuntimeMetadata) {
networkRuntimeMetadata.firstTokenTimeoutDerived =
!cleanEnv.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS
}
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN
if (options?.resumeInterruptedTurn === false) {
delete cleanEnv.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
@ -1206,7 +1290,10 @@ export class ConversationService {
const explicitProviderEnv = explicitProvider
? await this.providerService.getProviderRuntimeEnv(explicitProvider.id)
: null
const networkEnv = buildNetworkEnvironment(await loadNetworkSettings(), cleanEnv)
const networkEnv = buildNetworkEnvironment(
networkSettingsOverride ?? await loadNetworkSettings(),
cleanEnv,
)
const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled
if (explicitProviderEnv && options?.model?.trim()) {
explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim()

View File

@ -24,6 +24,10 @@ import {
import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js'
import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js'
import { diagnosticsService } from './diagnosticsService.js'
import {
buildNetworkEnvironment,
loadNetworkSettings,
} from './networkSettings.js'
import { resolveLocalIndexMode } from './localIndex/config.js'
import {
captureScheduledRunReadModelTarget,
@ -869,6 +873,10 @@ export class CronScheduler {
explicitProviderEnv?.ANTHROPIC_MODEL ||
cleanEnv.ANTHROPIC_MODEL,
)
const networkEnv = buildNetworkEnvironment(
await loadNetworkSettings(),
cleanEnv,
)
return {
...cleanEnv,
@ -887,6 +895,7 @@ export class CronScheduler {
...(this.shouldMarkManagedOAuth(task.providerId)
? await this.buildOfficialOAuthEnv()
: {}),
...networkEnv,
...attributionHeaderEnv,
}
}

View File

@ -17,7 +17,7 @@ import {
import type { GrokOAuthTokenResponse } from '../../services/grokAuth/types.js'
import { logTokenRefreshFailure } from './oauthRefreshLog.js'
import {
getManualNetworkProxyUrl,
getNetworkProxyUrl,
loadNetworkSettings,
} from './networkSettings.js'
@ -239,7 +239,7 @@ export class HahaGrokOAuthService {
private async getTokenFetchOptions(): Promise<GrokTokenFetchOptions> {
const settings = await loadNetworkSettings()
return {
proxyUrl: getManualNetworkProxyUrl(settings),
proxyUrl: getNetworkProxyUrl(settings),
timeoutMs: settings.aiRequestTimeoutMs,
}
}

View File

@ -31,6 +31,11 @@ import type {
SubscriptionType,
} from '../../services/oauth/types.js'
import { getOauthConfig } from '../../constants/oauth.js'
import {
getNetworkProxyFetchOptions,
getNetworkProxyUrl,
loadNetworkSettings,
} from './networkSettings.js'
export type StoredOAuthTokens = {
accessToken: string
@ -48,9 +53,13 @@ export type OAuthSession = {
createdAt: number
}
type RefreshFn = (refreshToken: string, opts?: { scopes?: string[] }) => Promise<OAuthTokens>
type RefreshFn = (
refreshToken: string,
opts?: { scopes?: string[]; proxyUrl?: string | null },
) => Promise<OAuthTokens>
type FetchProfileFn = (
accessToken: string,
opts?: { proxyUrl?: string | null },
) => Promise<{ subscriptionType: SubscriptionType | null }>
const SESSION_TTL_MS = 5 * 60 * 1000
@ -167,7 +176,10 @@ export class HahaOAuthService {
session.codeVerifier,
session.serverPort,
)
const profile = await this.fetchProfileFn(response.access_token)
const networkSettings = await loadNetworkSettings()
const profile = await this.fetchProfileFn(response.access_token, {
proxyUrl: getNetworkProxyUrl(networkSettings),
})
const tokens: StoredOAuthTokens = {
accessToken: response.access_token,
@ -199,11 +211,14 @@ export class HahaOAuthService {
const timeoutId = setTimeout(() => controller.abort(), 15_000)
let res: Response
try {
res = await fetch(getOauthConfig().TOKEN_URL, {
const tokenUrl = getOauthConfig().TOKEN_URL
const networkSettings = await loadNetworkSettings()
res = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal,
...getNetworkProxyFetchOptions(networkSettings, tokenUrl),
})
} finally {
clearTimeout(timeoutId)
@ -227,8 +242,10 @@ export class HahaOAuthService {
if (!tokens.refreshToken) return null
try {
const networkSettings = await loadNetworkSettings()
const refreshed = await this.refreshFn(tokens.refreshToken, {
scopes: tokens.scopes,
proxyUrl: getNetworkProxyUrl(networkSettings),
})
const updated: StoredOAuthTokens = {
accessToken: refreshed.accessToken,

View File

@ -29,7 +29,7 @@ import {
} from '../../services/openaiAuth/client.js'
import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js'
import {
getManualNetworkProxyUrl,
getNetworkProxyUrl,
loadNetworkSettings,
} from './networkSettings.js'
@ -365,7 +365,7 @@ export class HahaOpenAIOAuthService {
private async getOpenAITokenFetchOptions(): Promise<OpenAITokenFetchOptions> {
const networkSettings = await loadNetworkSettings()
return {
proxyUrl: getManualNetworkProxyUrl(networkSettings),
proxyUrl: getNetworkProxyUrl(networkSettings),
timeoutMs: networkSettings.aiRequestTimeoutMs,
}
}

View File

@ -1,5 +1,5 @@
import { SettingsService } from './settingsService.js'
import { getProxyFetchOptions } from '../../utils/proxy.js'
import { getProxyFetchOptions, getProxyUrl } from '../../utils/proxy.js'
export type NetworkProxyMode = 'direct' | 'system' | 'manual'
@ -27,11 +27,13 @@ export type NetworkSettings = {
export const DEFAULT_AI_REQUEST_TIMEOUT_MS = 600_000
export const MIN_AI_REQUEST_TIMEOUT_MS = 30_000
export const MAX_AI_REQUEST_TIMEOUT_MS = 1_800_000
export const SYSTEM_PROXY_URL_ENV = 'CC_HAHA_SYSTEM_PROXY_URL'
export const SYSTEM_PROXY_ERROR_ENV = 'CC_HAHA_SYSTEM_PROXY_ERROR'
const DEFAULT_NETWORK_SETTINGS: NetworkSettings = {
aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS,
proxy: {
mode: 'direct',
mode: 'system',
url: '',
},
}
@ -58,9 +60,10 @@ function parseProxy(value: unknown): NetworkSettings['proxy'] {
}
const record = value as Record<string, unknown>
const mode = isNetworkProxyMode(record.mode) ? record.mode : DEFAULT_NETWORK_SETTINGS.proxy.mode
return {
mode: isNetworkProxyMode(record.mode) ? record.mode : DEFAULT_NETWORK_SETTINGS.proxy.mode,
url: typeof record.url === 'string' ? record.url.trim() : '',
mode,
url: mode === 'manual' && typeof record.url === 'string' ? record.url.trim() : '',
}
}
@ -87,6 +90,29 @@ export function getManualNetworkProxyUrl(settings: NetworkSettings): string | un
return url || undefined
}
export function getNetworkProxyUrl(
settings: NetworkSettings,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (settings.proxy.mode === 'manual') return getManualNetworkProxyUrl(settings) ?? null
if (settings.proxy.mode === 'system') {
const bridgeUrl = env[SYSTEM_PROXY_URL_ENV]?.trim()
if (bridgeUrl) return bridgeUrl
const bridgeError = env[SYSTEM_PROXY_ERROR_ENV]?.trim()
if (bridgeError) {
throw new Error(bridgeError)
}
// Non-Electron/headless server launches have no host resolver bridge. In
// that environment, "system" retains the conventional process proxy
// contract. The Electron host clears inherited proxy variables before
// spawning the server, so desktop requests can only use its bridge.
return getProxyUrl(env) ?? null
}
return null
}
export function mergeLoopbackNoProxy(existing: string | undefined): string {
const entries = (existing ?? '')
.split(/[,\s]+/)
@ -114,10 +140,12 @@ export function buildNetworkEnvironment(
env.HTTPS_PROXY = ''
env.http_proxy = ''
env.https_proxy = ''
env.ALL_PROXY = ''
env.all_proxy = ''
return env
}
const proxyUrl = getManualNetworkProxyUrl(settings)
const proxyUrl = getNetworkProxyUrl(settings, baseEnv)
if (proxyUrl) {
const noProxy = mergeLoopbackNoProxy(baseEnv.no_proxy || baseEnv.NO_PROXY)
@ -125,8 +153,17 @@ export function buildNetworkEnvironment(
env.HTTPS_PROXY = proxyUrl
env.http_proxy = proxyUrl
env.https_proxy = proxyUrl
env.ALL_PROXY = proxyUrl
env.all_proxy = proxyUrl
env.NO_PROXY = noProxy
env.no_proxy = noProxy
} else {
env.HTTP_PROXY = ''
env.HTTPS_PROXY = ''
env.http_proxy = ''
env.https_proxy = ''
env.ALL_PROXY = ''
env.all_proxy = ''
}
return env
@ -137,14 +174,10 @@ export function getNetworkProxyFetchOptions(
targetUrl: string | URL,
): ReturnType<typeof getProxyFetchOptions> {
const noProxy = mergeLoopbackNoProxy(process.env.no_proxy || process.env.NO_PROXY)
if (settings.proxy.mode === 'system') {
return getProxyFetchOptions({ targetUrl, noProxy })
}
const proxyUrl = getNetworkProxyUrl(settings)
return getProxyFetchOptions({
proxyUrl: settings.proxy.mode === 'manual'
? getManualNetworkProxyUrl(settings) ?? null
: null,
proxyUrl,
targetUrl,
noProxy,
})

View File

@ -8,6 +8,11 @@
import { ProviderService } from './providerService.js'
import { getPresetAuthStrategy } from './providerRuntimeEnv.js'
import {
getNetworkProxyFetchOptions,
loadNetworkSettings,
type NetworkSettings,
} from './networkSettings.js'
import { sessionService } from './sessionService.js'
import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js'
import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js'
@ -162,6 +167,7 @@ export async function generateTitle(
try {
const providerService = new ProviderService()
const networkSettings = await loadNetworkSettings()
if (providerId === null) return null
let resolvedProvider = providerId
@ -182,6 +188,7 @@ export async function generateTitle(
trimmed,
resolvedProvider.models.haiku || resolvedProvider.models.main,
languagePreference,
networkSettings,
)
}
@ -209,6 +216,7 @@ export async function generateTitle(
content: buildTitleUserPrompt(trimmed, languagePreference, strictLanguage),
}],
},
networkSettings,
)
if (!response) return null
return parseGeneratedTitleText(response)
@ -224,6 +232,7 @@ async function generateOpenAIOfficialTitle(
trimmed: string,
model: string,
languagePreference?: TitleLanguagePreference | null,
networkSettings?: NetworkSettings,
): Promise<string | null> {
const tokens = await hahaOpenAIOAuthService.ensureFreshTokens()
if (!tokens?.accessToken) return null
@ -257,6 +266,9 @@ async function generateOpenAIOfficialTitle(
headers,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(15_000),
...(networkSettings
? getNetworkProxyFetchOptions(networkSettings, OPENAI_CODEX_API_ENDPOINT)
: {}),
})
if (!response.ok || !response.body) return null
@ -278,6 +290,7 @@ async function fetchAnthropicTitleResponse(
url: string,
requestHeaders: Record<string, string>,
requestBody: Record<string, unknown>,
networkSettings: NetworkSettings,
): Promise<string | null> {
let response = await fetch(url, {
method: 'POST',
@ -287,6 +300,7 @@ async function fetchAnthropicTitleResponse(
thinking: { type: 'disabled' },
}),
signal: AbortSignal.timeout(15_000),
...getNetworkProxyFetchOptions(networkSettings, url),
})
if (!response.ok && response.status >= 400 && response.status < 500) {
@ -295,6 +309,7 @@ async function fetchAnthropicTitleResponse(
headers: requestHeaders,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(15_000),
...getNetworkProxyFetchOptions(networkSettings, url),
})
}

View File

@ -1,4 +1,4 @@
import { describe, expect, test } from 'bun:test'
import { describe, expect, mock, test } from 'bun:test'
import {
buildGrokAuthorizeUrl,
exchangeGrokCodeForTokens,
@ -8,6 +8,7 @@ import {
GROK_OAUTH_CLIENT_ID,
GROK_OAUTH_TOKEN_ENDPOINT,
refreshGrokTokens,
resolveGrokProxyFetchOptions,
} from './client.js'
describe('Grok OAuth client', () => {
@ -61,4 +62,19 @@ describe('Grok OAuth client', () => {
expect(requests[1]!.body.get('grant_type')).toBe('refresh_token')
expect(requests[1]!.body.get('refresh_token')).toBe('refresh')
})
test('distinguishes explicit direct proxy null from inherited proxy undefined', async () => {
const resolve = mock(async (proxyUrl: string | null) => ({
headers: { 'x-test-proxy-url': proxyUrl ?? 'direct' },
}))
expect(await resolveGrokProxyFetchOptions(undefined, resolve)).toEqual({})
expect(resolve).not.toHaveBeenCalled()
expect(await resolveGrokProxyFetchOptions(null, resolve)).toEqual({
headers: { 'x-test-proxy-url': 'direct' },
})
expect(resolve).toHaveBeenCalledTimes(1)
expect(resolve).toHaveBeenCalledWith(null)
})
})

View File

@ -97,9 +97,7 @@ async function requestGrokTokens(
options: GrokTokenFetchOptions,
): Promise<GrokOAuthTokenResponse> {
const fetchOverride = options.fetchOverride ?? globalThis.fetch
const proxyOptions = options.proxyUrl
? getGrokProxyFetchOptions(options.proxyUrl)
: {}
const proxyOptions = resolveGrokProxyFetchOptions(options.proxyUrl)
const response = await fetchOverride(GROK_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
@ -123,8 +121,21 @@ async function requestGrokTokens(
return (await response.json()) as GrokOAuthTokenResponse
}
type GrokProxyFetchOptionsResolver = (
proxyUrl: string | null,
) => Promise<RequestInit>
/** @internal Exported for deterministic proxy-mode contract tests. */
export async function resolveGrokProxyFetchOptions(
proxyUrl: string | null | undefined,
resolve: GrokProxyFetchOptionsResolver = getGrokProxyFetchOptions,
): Promise<RequestInit> {
if (proxyUrl === undefined) return {}
return resolve(proxyUrl)
}
async function getGrokProxyFetchOptions(
proxyUrl: string,
proxyUrl: string | null,
): Promise<RequestInit> {
const { getProxyFetchOptions } = await import('../../utils/proxy.js')
return getProxyFetchOptions({ proxyUrl })

View File

@ -32,12 +32,18 @@ describe('Grok Responses fetch adapter', () => {
})
test('maps Anthropic messages to the exact subscription endpoint and identity', async () => {
let call: { url: string; headers: Headers; body: Record<string, unknown> } | undefined
let call: {
url: string
headers: Headers
body: Record<string, unknown>
proxy?: string
} | undefined
const fetchOverride: typeof fetch = async (input, init) => {
call = {
url: String(input),
headers: new Headers(init?.headers),
body: JSON.parse(String(init?.body)),
proxy: (init as RequestInit & { proxy?: string } | undefined)?.proxy,
}
return new Response([
'event: response.completed',
@ -53,7 +59,8 @@ describe('Grok Responses fetch adapter', () => {
output_config: { effort: 'max' },
messages: [{ role: 'user', content: 'Say ok' }],
}),
})
proxy: 'http://127.0.0.1:17890',
} as RequestInit & { proxy: string })
expect(call?.url).toBe(GROK_CLI_API_ENDPOINT)
expect(call?.headers.get('Authorization')).toBe('Bearer access')
@ -64,6 +71,7 @@ describe('Grok Responses fetch adapter', () => {
expect(call?.body.model).toBe('grok-4.5')
expect(call?.body.reasoning).toEqual({ effort: 'high' })
expect(call?.body.stream).toBe(true)
expect(call?.proxy).toBe('http://127.0.0.1:17890')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
type: 'message', content: [{ type: 'text', text: 'ok' }],

View File

@ -59,6 +59,7 @@ export function buildGrokFetch(
void source
const requestUpstream = (requestHeaders: Headers) => inner(GROK_CLI_API_ENDPOINT, {
...init,
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(transformedBody),

View File

@ -20,6 +20,7 @@ import {
import type { AccountInfo } from '../../utils/config.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { getAxiosProxyOptions } from '../../utils/proxy.js'
import { getOauthProfileFromOauthToken } from './getOauthProfile.js'
import type {
BillingType,
@ -145,7 +146,10 @@ export async function exchangeCodeForTokens(
export async function refreshOAuthToken(
refreshToken: string,
{ scopes: requestedScopes }: { scopes?: string[] } = {},
{
scopes: requestedScopes,
proxyUrl,
}: { scopes?: string[]; proxyUrl?: string | null } = {},
): Promise<OAuthTokens> {
const requestBody = {
grant_type: 'refresh_token',
@ -166,6 +170,7 @@ export async function refreshOAuthToken(
const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, {
headers: { 'Content-Type': 'application/json' },
timeout: 15000,
...getAxiosProxyOptions(proxyUrl),
})
if (response.status !== 200) {
@ -208,7 +213,7 @@ export async function refreshOAuthToken(
const profileInfo = haveProfileAlready
? null
: await fetchProfileInfo(accessToken)
: await fetchProfileInfo(accessToken, { proxyUrl })
// Update the stored properties if they have changed
if (profileInfo && config.oauthAccount) {
@ -352,7 +357,10 @@ export function isOAuthTokenExpired(expiresAt: number | null): boolean {
return expiresWithBuffer >= expiresAt
}
export async function fetchProfileInfo(accessToken: string): Promise<{
export async function fetchProfileInfo(
accessToken: string,
options: { proxyUrl?: string | null } = {},
): Promise<{
subscriptionType: SubscriptionType | null
displayName?: string
rateLimitTier: RateLimitTier | null
@ -362,7 +370,7 @@ export async function fetchProfileInfo(accessToken: string): Promise<{
subscriptionCreatedAt?: string
rawProfile?: OAuthProfileResponse
}> {
const profile = await getOauthProfileFromOauthToken(accessToken)
const profile = await getOauthProfileFromOauthToken(accessToken, options)
const orgType = profile?.organization?.organization_type
// Reuse the logic from fetchSubscriptionType

View File

@ -4,6 +4,12 @@ import type { OAuthProfileResponse } from 'src/services/oauth/types.js'
import { getAnthropicApiKey } from 'src/utils/auth.js'
import { getGlobalConfig } from 'src/utils/config.js'
import { logError } from 'src/utils/log.js'
import { getAxiosProxyOptions } from 'src/utils/proxy.js'
export type OAuthProfileFetchOptions = {
proxyUrl?: string | null
}
export async function getOauthProfileFromApiKey(): Promise<
OAuthProfileResponse | undefined
> {
@ -36,6 +42,7 @@ export async function getOauthProfileFromApiKey(): Promise<
export async function getOauthProfileFromOauthToken(
accessToken: string,
options: OAuthProfileFetchOptions = {},
): Promise<OAuthProfileResponse | undefined> {
const endpoint = `${getOauthConfig().BASE_API_URL}/api/oauth/profile`
try {
@ -45,6 +52,7 @@ export async function getOauthProfileFromOauthToken(
'Content-Type': 'application/json',
},
timeout: 10000,
...getAxiosProxyOptions(options.proxyUrl),
})
return response.data
} catch (error) {

View File

@ -52,6 +52,7 @@ describe('buildOpenAICodexFetch', () => {
url: string
headers: Record<string, string>
body: Record<string, unknown>
proxy?: string
}> = []
const fetchOverride: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers)
@ -59,6 +60,7 @@ describe('buildOpenAICodexFetch', () => {
url: String(input),
headers: Object.fromEntries(headers.entries()),
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
proxy: (init as RequestInit & { proxy?: string } | undefined)?.proxy,
})
return Response.json({
id: 'resp_123',
@ -83,7 +85,8 @@ describe('buildOpenAICodexFetch', () => {
max_tokens: 64,
messages: [{ role: 'user', content: 'Say ok' }],
}),
})
proxy: 'http://127.0.0.1:17890',
} as RequestInit & { proxy: string })
expect(upstreamCalls).toHaveLength(1)
expect(upstreamCalls[0].url).toBe(OPENAI_CODEX_API_ENDPOINT)
@ -92,6 +95,7 @@ describe('buildOpenAICodexFetch', () => {
expect(upstreamCalls[0].headers.originator).toBe('codex_cli_rs')
expect(upstreamCalls[0].body.model).toBe('gpt-5.5')
expect(upstreamCalls[0].body.reasoning).toEqual({ effort: 'medium' })
expect(upstreamCalls[0].proxy).toBe('http://127.0.0.1:17890')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
type: 'message',

View File

@ -79,6 +79,7 @@ export function buildOpenAICodexFetch(
)
const upstream = await inner(OPENAI_CODEX_API_ENDPOINT, {
...init,
method: 'POST',
headers,
body: JSON.stringify(upstreamBody),

View File

@ -1,5 +1,9 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { getProxyFetchOptions, shouldBypassProxy } from './proxy.js'
import {
getAxiosProxyOptions,
getProxyFetchOptions,
shouldBypassProxy,
} from './proxy.js'
const originalEnv = {
HTTP_PROXY: process.env.HTTP_PROXY,
@ -75,4 +79,14 @@ describe('proxy environment handling', () => {
targetUrl: 'https://api.example.com',
}).proxy).toBe('http://127.0.0.1:1181')
})
test('distinguishes inherited, direct, and explicit Axios proxy routing', () => {
expect(getAxiosProxyOptions(undefined)).toEqual({})
expect(getAxiosProxyOptions(null)).toEqual({ proxy: false })
const explicit = getAxiosProxyOptions('http://user:password@127.0.0.1:17890')
expect(explicit.proxy).toBe(false)
expect(explicit.httpAgent).toBeDefined()
expect(explicit.httpsAgent).toBe(explicit.httpAgent)
})
})

View File

@ -2,7 +2,7 @@
// dynamically in getAWSClientProxyConfig() to defer ~929KB of AWS SDK.
// undici is lazy-required inside getProxyAgent/configureGlobalAgents to defer
// ~1.5MB when no HTTPS_PROXY/mTLS env vars are set (the common case).
import axios, { type AxiosInstance } from 'axios'
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
import type { LookupOptions } from 'dns'
import type { Agent } from 'http'
import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent'
@ -182,6 +182,25 @@ function createHttpsProxyAgent(
return new HttpsProxyAgent(proxyUrl, { ...agentOptions, ...extra })
}
/**
* Build request-local Axios routing without mutating the process-wide Axios
* defaults. `undefined` preserves the CLI's existing inherited environment,
* while `null` is an explicit direct connection.
*/
export function getAxiosProxyOptions(
proxyUrl: string | null | undefined,
): Pick<AxiosRequestConfig, 'proxy' | 'httpAgent' | 'httpsAgent'> {
if (proxyUrl === undefined) return {}
if (!proxyUrl) return { proxy: false }
const proxyAgent = createHttpsProxyAgent(proxyUrl)
return {
proxy: false,
httpAgent: proxyAgent,
httpsAgent: proxyAgent,
}
}
/**
* Axios instance with its own proxy agent. Same NO_PROXY/mTLS/CA
* resolution as the global interceptor, but agent options stay