From c06f958e7cb749022c91119fe1f92cbd49fb72ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 16 Jul 2026 17:33:33 +0800 Subject: [PATCH] fix(proxy): route AI sessions through dynamic system proxy #953 --- .../electron/services/serverRuntime.test.ts | 109 ++- desktop/electron/services/serverRuntime.ts | 63 +- .../electron/services/sidecarManager.test.ts | 80 +- desktop/electron/services/sidecarManager.ts | 79 +- .../services/systemProxyBridge.test.ts | 379 ++++++++++ .../electron/services/systemProxyBridge.ts | 681 ++++++++++++++++++ .../src/__tests__/generalSettings.test.tsx | 2 +- desktop/src/i18n/locales/en.ts | 10 +- desktop/src/i18n/locales/jp.ts | 10 +- desktop/src/i18n/locales/kr.ts | 10 +- desktop/src/i18n/locales/zh-TW.ts | 10 +- desktop/src/i18n/locales/zh.ts | 10 +- desktop/src/stores/settingsStore.test.ts | 62 +- desktop/src/stores/settingsStore.ts | 8 +- src/cli/__tests__/structuredIO.test.ts | 52 ++ src/cli/structuredIO.ts | 14 + .../__tests__/conversation-service.test.ts | 164 +++++ .../__tests__/cron-scheduler-launcher.test.ts | 98 +++ .../__tests__/haha-grok-oauth-service.test.ts | 65 ++ .../__tests__/haha-oauth-service.test.ts | 68 +- .../haha-openai-oauth-service.test.ts | 101 +++ src/server/__tests__/network-settings.test.ts | 164 ++++- src/server/__tests__/title-service.test.ts | 18 + src/server/services/conversationService.ts | 99 ++- src/server/services/cronScheduler.ts | 9 + src/server/services/hahaGrokOAuthService.ts | 4 +- src/server/services/hahaOAuthService.ts | 23 +- src/server/services/hahaOpenAIOAuthService.ts | 4 +- src/server/services/networkSettings.ts | 55 +- src/server/services/titleService.ts | 15 + src/services/grokAuth/client.test.ts | 18 +- src/services/grokAuth/client.ts | 19 +- src/services/grokAuth/fetch.test.ts | 12 +- src/services/grokAuth/fetch.ts | 1 + src/services/oauth/client.ts | 16 +- src/services/oauth/getOauthProfile.ts | 8 + src/services/openaiAuth/fetch.test.ts | 6 +- src/services/openaiAuth/fetch.ts | 1 + src/utils/proxy.test.ts | 16 +- src/utils/proxy.ts | 21 +- 40 files changed, 2404 insertions(+), 180 deletions(-) create mode 100644 desktop/electron/services/systemProxyBridge.test.ts create mode 100644 desktop/electron/services/systemProxyBridge.ts diff --git a/desktop/electron/services/serverRuntime.test.ts b/desktop/electron/services/serverRuntime.test.ts index 665715bf..fc5d8c82 100644 --- a/desktop/electron/services/serverRuntime.test.ts +++ b/desktop/electron/services/serverRuntime.test.ts @@ -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 + 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(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({ diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index 1a2a8132..546bf819 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -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) => 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 private readonly localAccessToken = randomBytes(32).toString('base64url') private sidecarEnvPromise: Promise | 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 | null = null + private lifecycleGeneration = 0 private startingServer: ServerStartState | null = null private adapterRestartPromise: Promise | 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 { + private async startServerOnce(generation: number): Promise { // 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 { - 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 { - 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 diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index e9c351e5..65d39d57 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -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', () => { diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index 4a23a028..d194082f 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -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, } } diff --git a/desktop/electron/services/systemProxyBridge.test.ts b/desktop/electron/services/systemProxyBridge.test.ts new file mode 100644 index 00000000..ecc8e525 --- /dev/null +++ b/desktop/electron/services/systemProxyBridge.test.ts @@ -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 = [] +const bridges: SystemProxyBridge[] = [] +const sockets = new Set() + +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(resolve => { acceptTarget = resolve }) + let closeTarget!: () => void + const targetClosed = new Promise(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(resolve => setTimeout(() => resolve('timed out'), 500)), + ])).resolves.toBe('stopped') + await expect(Promise.race([ + targetClosed.then(() => 'closed'), + new Promise(resolve => setTimeout(() => resolve('timed out'), 500)), + ])).resolves.toBe('closed') + }) +}) + +async function startBridge( + resolver: (url: string) => Promise, +): Promise { + const bridge = new SystemProxyBridge(resolver) + bridges.push(bridge) + return await bridge.start() +} + +async function createProxyServer(label: string): Promise { + const server = http.createServer((_request, response) => response.end(label)) + servers.push(server) + return await listen(server) +} + +function listen(server: http.Server | net.Server): Promise { + 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 { + return new Promise(resolve => { + if ('closeAllConnections' in server) server.closeAllConnections() + server.close(() => resolve()) + }) +} + +async function reserveClosedPort(): Promise { + 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 = {}, +): Promise { + 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 { + 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 { + 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) + }) +} diff --git a/desktop/electron/services/systemProxyBridge.ts b/desktop/electron/services/systemProxyBridge.ts new file mode 100644 index 00000000..889e6750 --- /dev/null +++ b/desktop/electron/services/systemProxyBridge.ts @@ -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 + stop(): Promise +} + +type ProxyEndpoint = Required> +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 | null = null + private lifecycleGeneration = 0 + private readonly clientSockets = new Set() + private readonly outboundSockets = new Set() + + constructor(private readonly resolveSystemProxy: (url: string) => Promise) {} + + start(): Promise { + if (this.startPromise) return this.startPromise + const generation = ++this.lifecycleGeneration + this.startPromise = this.startOnce(generation) + return this.startPromise + } + + async stop(): Promise { + ++this.lifecycleGeneration + const startPromise = this.startPromise + this.startPromise = null + const server = this.server + this.server = null + const closing = server?.listening + ? new Promise(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 { + 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((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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return secure ? connectTls(endpoint.host, endpoint.port) : connectTcp(endpoint.host, endpoint.port) +} + +function connectTcp(host: string, port: number): Promise { + 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 { + 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 { + 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 { + 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 { + return readFromSocket(socket, buffer => buffer.length >= length ? length : null) +} + +function readUntil(socket: Duplex, marker: Buffer, maxBytes: number): Promise { + 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 { + 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 {} diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index b6508fd5..d895ba39 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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]! diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 930a5ed2..98958760 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index f3c0103b..d4cac6be 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1211,13 +1211,13 @@ export const jp: Record = { '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 = { '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 のドメインプリフライトをスキップします。', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 3148652b..a4d65309 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1211,13 +1211,13 @@ export const kr: Record = { '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 = { '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의 도메인 사전 확인을 건너뜁니다.', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 9bf19cc2..f1874648 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1211,13 +1211,13 @@ export const zh: Record = { '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 = { '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 的域名預檢,避免第三方服務商或受限網路下出現誤報失敗。', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index c3a8eb8d..5856c0e6 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1211,13 +1211,13 @@ export const zh: Record = { '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 = { '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 的域名预检,避免第三方服务商或受限网络下出现误报失败。', diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 66e1bc36..61b855ed 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -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', () => ({ diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 2f86375d..1d1193bf 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -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, diff --git a/src/cli/__tests__/structuredIO.test.ts b/src/cli/__tests__/structuredIO.test.ts index 43f7c1b3..588a9e2a 100644 --- a/src/cli/__tests__/structuredIO.test.ts +++ b/src/cli/__tests__/structuredIO.test.ts @@ -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() + }) }) diff --git a/src/cli/structuredIO.ts b/src/cli/structuredIO.ts index c70b3f70..5737d0c6 100644 --- a/src/cli/structuredIO.ts +++ b/src/cli/structuredIO.ts @@ -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() diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index dd097b0a..af3a2ad8 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -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 @@ -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 + + 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 }) diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index f23109b3..1e8937cf 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -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') diff --git a/src/server/__tests__/haha-grok-oauth-service.test.ts b/src/server/__tests__/haha-grok-oauth-service.test.ts index 58a1447c..076cc7a2 100644 --- a/src/server/__tests__/haha-grok-oauth-service.test.ts +++ b/src/server/__tests__/haha-grok-oauth-service.test.ts @@ -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', + }) + } + }) }) diff --git a/src/server/__tests__/haha-oauth-service.test.ts b/src/server/__tests__/haha-oauth-service.test.ts index 3965656e..99dbf25d 100644 --- a/src/server/__tests__/haha-oauth-service.test.ts +++ b/src/server/__tests__/haha-oauth-service.test.ts @@ -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') diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index 44bf512e..2b486159 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -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 { 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 }) diff --git a/src/server/__tests__/network-settings.test.ts b/src/server/__tests__/network-settings.test.ts index f9e158c3..0d5b8024 100644 --- a/src/server/__tests__/network-settings.test.ts +++ b/src/server/__tests__/network-settings.test.ts @@ -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> 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: '', }) }) }) diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index a359d747..ca6483e7 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -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 body: Record + 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, + 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 () => { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 90ce0729..f0e286f8 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -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 - 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 @@ -324,7 +349,15 @@ export class ConversationService { // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.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 @@ -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 { - 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 { + 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 = { + ...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> { // 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() diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 68f02bd2..b455edde 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -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, } } diff --git a/src/server/services/hahaGrokOAuthService.ts b/src/server/services/hahaGrokOAuthService.ts index f403489c..1cdda300 100644 --- a/src/server/services/hahaGrokOAuthService.ts +++ b/src/server/services/hahaGrokOAuthService.ts @@ -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 { const settings = await loadNetworkSettings() return { - proxyUrl: getManualNetworkProxyUrl(settings), + proxyUrl: getNetworkProxyUrl(settings), timeoutMs: settings.aiRequestTimeoutMs, } } diff --git a/src/server/services/hahaOAuthService.ts b/src/server/services/hahaOAuthService.ts index 9982658e..b5cda3b1 100644 --- a/src/server/services/hahaOAuthService.ts +++ b/src/server/services/hahaOAuthService.ts @@ -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 +type RefreshFn = ( + refreshToken: string, + opts?: { scopes?: string[]; proxyUrl?: string | null }, +) => Promise 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, diff --git a/src/server/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index 93c781c0..23755d23 100644 --- a/src/server/services/hahaOpenAIOAuthService.ts +++ b/src/server/services/hahaOpenAIOAuthService.ts @@ -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 { const networkSettings = await loadNetworkSettings() return { - proxyUrl: getManualNetworkProxyUrl(networkSettings), + proxyUrl: getNetworkProxyUrl(networkSettings), timeoutMs: networkSettings.aiRequestTimeoutMs, } } diff --git a/src/server/services/networkSettings.ts b/src/server/services/networkSettings.ts index 27bca2ac..32800ca7 100644 --- a/src/server/services/networkSettings.ts +++ b/src/server/services/networkSettings.ts @@ -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 + 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 { 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, }) diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index ae4b4433..4f9dbe02 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -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 { 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, requestBody: Record, + networkSettings: NetworkSettings, ): Promise { 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), }) } diff --git a/src/services/grokAuth/client.test.ts b/src/services/grokAuth/client.test.ts index 558590b9..49c47199 100644 --- a/src/services/grokAuth/client.test.ts +++ b/src/services/grokAuth/client.test.ts @@ -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) + }) }) diff --git a/src/services/grokAuth/client.ts b/src/services/grokAuth/client.ts index e4552650..e54a6398 100644 --- a/src/services/grokAuth/client.ts +++ b/src/services/grokAuth/client.ts @@ -97,9 +97,7 @@ async function requestGrokTokens( options: GrokTokenFetchOptions, ): Promise { 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 + +/** @internal Exported for deterministic proxy-mode contract tests. */ +export async function resolveGrokProxyFetchOptions( + proxyUrl: string | null | undefined, + resolve: GrokProxyFetchOptionsResolver = getGrokProxyFetchOptions, +): Promise { + if (proxyUrl === undefined) return {} + return resolve(proxyUrl) +} + async function getGrokProxyFetchOptions( - proxyUrl: string, + proxyUrl: string | null, ): Promise { const { getProxyFetchOptions } = await import('../../utils/proxy.js') return getProxyFetchOptions({ proxyUrl }) diff --git a/src/services/grokAuth/fetch.test.ts b/src/services/grokAuth/fetch.test.ts index c119ccfa..0bb9416a 100644 --- a/src/services/grokAuth/fetch.test.ts +++ b/src/services/grokAuth/fetch.test.ts @@ -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 } | undefined + let call: { + url: string + headers: Headers + body: Record + 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' }], diff --git a/src/services/grokAuth/fetch.ts b/src/services/grokAuth/fetch.ts index 371cfda1..4d12a1c6 100644 --- a/src/services/grokAuth/fetch.ts +++ b/src/services/grokAuth/fetch.ts @@ -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), diff --git a/src/services/oauth/client.ts b/src/services/oauth/client.ts index 76bfdce1..b9622580 100644 --- a/src/services/oauth/client.ts +++ b/src/services/oauth/client.ts @@ -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 { 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 diff --git a/src/services/oauth/getOauthProfile.ts b/src/services/oauth/getOauthProfile.ts index 21bbf097..1dd9b160 100644 --- a/src/services/oauth/getOauthProfile.ts +++ b/src/services/oauth/getOauthProfile.ts @@ -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 { 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) { diff --git a/src/services/openaiAuth/fetch.test.ts b/src/services/openaiAuth/fetch.test.ts index a88b41e2..f517d74f 100644 --- a/src/services/openaiAuth/fetch.test.ts +++ b/src/services/openaiAuth/fetch.test.ts @@ -52,6 +52,7 @@ describe('buildOpenAICodexFetch', () => { url: string headers: Record body: Record + 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, + 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', diff --git a/src/services/openaiAuth/fetch.ts b/src/services/openaiAuth/fetch.ts index 36772ae3..808551f2 100644 --- a/src/services/openaiAuth/fetch.ts +++ b/src/services/openaiAuth/fetch.ts @@ -79,6 +79,7 @@ export function buildOpenAICodexFetch( ) const upstream = await inner(OPENAI_CODEX_API_ENDPOINT, { + ...init, method: 'POST', headers, body: JSON.stringify(upstreamBody), diff --git a/src/utils/proxy.test.ts b/src/utils/proxy.test.ts index 04f60083..4f75f54d 100644 --- a/src/utils/proxy.test.ts +++ b/src/utils/proxy.test.ts @@ -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) + }) }) diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index de1f3e53..98ba3b16 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -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 { + 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