fix(desktop): stabilize Electron proxy and fullscreen handling

Electron desktop runs network-sensitive OpenAI OAuth token exchange in the sidecar process, so the sidecar now receives system proxy env derived from Electron's cross-platform proxy resolver and the OAuth token client uses the existing proxy fetch options. General manual proxy settings also document and preserve authenticated proxy URLs.

The macOS fullscreen black-screen path is addressed by avoiding native fullscreen Spaces for app fullscreen toggles and by leaving fullscreen before hiding or closing the window.

Constraint: Electron packaged apps may not inherit shell HTTPS_PROXY env when launched from Finder.
Constraint: Manual authenticated proxies must remain standard HTTP(S) proxy URLs for Bun/undici compatibility.
Rejected: Store proxy username and password as separate fields | would require new secret-storage semantics and migration beyond this bugfix.
Rejected: Use native macOS fullscreen Spaces for the desktop app | reproduced black-screen behavior when hiding/closing from fullscreen.
Confidence: high
Scope-risk: moderate
Directive: Do not remove sidecar proxy env injection without retesting OpenAI OAuth from a packaged app launched outside a shell.
Tested: bun test src/services/openaiAuth/client.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts
Tested: bun test src/server/__tests__/network-settings.test.ts
Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx --run
Tested: cd desktop && bun run check:electron
Tested: git diff --check
Not-tested: bun run check:server blocked by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
Not-tested: live OpenAI OAuth through a corporate authenticated proxy
This commit is contained in:
程序员阿江(Relakkes) 2026-06-02 21:37:35 +08:00
parent 232855fe77
commit 16f4137954
16 changed files with 599 additions and 33 deletions

View File

@ -85,6 +85,7 @@ function getServerRuntime() {
desktopRoot: unpackedRoot(),
appRoot: appRoot(),
h5DistDir: path.join(unpackedRoot(), 'dist'),
resolveSystemProxy: (url) => session.defaultSession.resolveProxy(url),
})
return serverRuntime
}
@ -287,6 +288,7 @@ async function createMainWindow() {
minHeight: MIN_WINDOW_HEIGHT,
show: false,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
fullscreenable: process.platform !== 'darwin',
webPreferences: {
preload: preloadPath(),
contextIsolation: true,

View File

@ -56,6 +56,57 @@ describe('Electron application menu service', () => {
expect(onNavigate).toHaveBeenNthCalledWith(2, 'settings')
})
it('routes macOS Hide through the provided safe hide action', () => {
const hide = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'darwin', { hide })
const appMenu = template[0]
const submenu = appMenu!.submenu as MenuItemConstructorOptions[]
const hideItem = submenu.find(item => item.label === 'Hide Claude Code Haha')
expect(hideItem).toBeDefined()
expect(hideItem?.accelerator).toBe('Command+H')
hideItem?.click?.({} as never, {} as never, {} as never)
expect(hide).toHaveBeenCalledTimes(1)
})
it('routes the Window close accelerator through the provided close action', () => {
const close = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'darwin', { close })
const closeItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Close Window')
expect(closeItem).toBeDefined()
expect(closeItem?.accelerator).toBe('CmdOrCtrl+W')
closeItem?.click?.({} as never, {} as never, {} as never)
expect(close).toHaveBeenCalledTimes(1)
})
it('routes the View fullscreen accelerator through the provided fullscreen action', () => {
const toggleFullScreen = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'darwin', { toggleFullScreen })
const fullScreenItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Toggle Full Screen')
expect(fullScreenItem).toBeDefined()
expect(fullScreenItem?.accelerator).toBe('Ctrl+Command+F')
fullScreenItem?.click?.({} as never, {} as never, {} as never)
expect(toggleFullScreen).toHaveBeenCalledTimes(1)
})
it('uses F11 for custom fullscreen on non-macOS platforms', () => {
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'linux', {})
const fullScreenItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Toggle Full Screen')
expect(fullScreenItem?.accelerator).toBe('F11')
})
it('keeps a settings entry available on non-macOS platforms', () => {
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'win32')
const fileMenu = template[0]
@ -90,4 +141,65 @@ describe('Electron application menu service', () => {
settingsItem?.click?.({} as never, {} as never, {} as never)
expect(send).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, 'settings')
})
it('installs hide as a safe fullscreen-aware window hide before app hide', async () => {
const appHide = vi.fn()
const onceHandlers = new Map<string, (...args: never[]) => void>()
const window = {
isFullScreen: () => true,
isSimpleFullScreen: () => false,
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
setFullScreen: vi.fn(),
hide: vi.fn(),
isDestroyed: () => false,
webContents: { send: vi.fn() },
}
const menuMocks = getElectronMenuMocks()
await installApplicationMenu(
{ name: 'Claude Code Haha', hide: appHide } as never,
() => window as never,
)
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
const hideItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Hide Claude Code Haha')
hideItem?.click?.({} as never, {} as never, {} as never)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
expect(appHide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
expect(appHide).toHaveBeenCalledTimes(1)
})
it('installs fullscreen as simple fullscreen on macOS instead of native Spaces', async () => {
const window = {
isSimpleFullScreen: () => false,
setSimpleFullScreen: vi.fn(),
isFullScreen: vi.fn(),
setFullScreen: vi.fn(),
webContents: { send: vi.fn() },
}
const menuMocks = getElectronMenuMocks()
await installApplicationMenu(
{ name: 'Claude Code Haha' } as never,
() => window as never,
)
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
const fullScreenItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Toggle Full Screen')
fullScreenItem?.click?.({} as never, {} as never, {} as never)
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(true)
expect(window.setFullScreen).not.toHaveBeenCalled()
})
})

View File

@ -1,12 +1,19 @@
import type { App, BrowserWindow, MenuItemConstructorOptions } from 'electron'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { hideWindowSafely, toggleWindowFullScreen } from './windows'
export type NativeMenuDestination = 'about' | 'settings'
type ApplicationMenuActions = {
hide?: () => void
close?: () => void
toggleFullScreen?: () => void
}
export function buildApplicationMenuTemplate(
appName: string,
onNavigate: (destination: NativeMenuDestination) => void,
platform = process.platform,
actions: ApplicationMenuActions = {},
): MenuItemConstructorOptions[] {
const appMenu: MenuItemConstructorOptions[] = platform === 'darwin'
? [{
@ -18,7 +25,7 @@ export function buildApplicationMenuTemplate(
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ label: `Hide ${appName}`, accelerator: 'Command+H', click: () => actions.hide?.() },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
@ -51,7 +58,11 @@ export function buildApplicationMenuTemplate(
{
label: 'View',
submenu: [
{ role: 'togglefullscreen' },
{
label: 'Toggle Full Screen',
accelerator: platform === 'darwin' ? 'Ctrl+Command+F' : 'F11',
click: () => actions.toggleFullScreen?.(),
},
],
},
{
@ -59,7 +70,7 @@ export function buildApplicationMenuTemplate(
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ role: 'close' },
{ label: 'Close Window', accelerator: 'CmdOrCtrl+W', click: () => actions.close?.() },
],
},
]
@ -69,6 +80,22 @@ export async function installApplicationMenu(app: App, getMainWindow: () => Brow
const { Menu } = await import('electron')
const template = buildApplicationMenuTemplate(app.name || 'Claude Code Haha', destination => {
getMainWindow()?.webContents.send(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, destination)
}, process.platform, {
hide: () => {
const window = getMainWindow()
if (!window) {
app.hide?.()
return
}
hideWindowSafely(window, () => app.hide?.())
},
close: () => {
getMainWindow()?.close()
},
toggleFullScreen: () => {
const window = getMainWindow()
if (window) toggleWindowFullScreen(window)
},
})
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}

View File

@ -4,6 +4,8 @@ import {
createServerPlan,
formatStartupError,
killSidecar,
mergeProxyEnv,
proxyUrlFromElectronProxyRules,
pushStartupLog,
reserveLocalPort,
SERVER_BIND_HOST,
@ -17,12 +19,15 @@ type ServerRuntimeOptions = {
desktopRoot: string
appRoot?: string
h5DistDir?: string
resolveSystemProxy?: (url: string) => Promise<string>
}
export class ElectronServerRuntime {
private readonly desktopRoot: string
private readonly appRoot: string
private readonly h5DistDir: string
private readonly resolveSystemProxy?: (url: string) => Promise<string>
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
private server: { url: string, child: SidecarChild } | null = null
private adapters: SidecarChild[] = []
private startupError: string | null = null
@ -32,6 +37,7 @@ export class ElectronServerRuntime {
this.desktopRoot = options.desktopRoot
this.appRoot = options.appRoot ?? options.desktopRoot
this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist')
this.resolveSystemProxy = options.resolveSystemProxy
}
async startServer(): Promise<string> {
@ -55,7 +61,7 @@ export class ElectronServerRuntime {
async restartAdaptersSidecars(): Promise<void> {
this.stopAdaptersSidecars()
const serverUrl = await this.getServerUrl()
this.startAdaptersSidecars(serverUrl)
await this.startAdaptersSidecars(serverUrl)
}
stopAll() {
@ -70,11 +76,13 @@ export class ElectronServerRuntime {
const port = await reserveLocalPort(SERVER_BIND_HOST)
const url = `http://${SERVER_CONTROL_HOST}:${port}`
const logs: string[] = []
const env = await this.resolveSidecarBaseEnv()
const plan = createServerPlan({
desktopRoot: this.desktopRoot,
appRoot: this.appRoot,
port,
h5DistDir: this.h5DistDir,
env,
})
try {
@ -83,7 +91,7 @@ export class ElectronServerRuntime {
await waitForServer(SERVER_CONTROL_HOST, port)
this.server = { url, child }
this.startupError = null
this.startAdaptersSidecars(url)
await this.startAdaptersSidecars(url)
return url
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@ -92,7 +100,8 @@ export class ElectronServerRuntime {
}
}
private startAdaptersSidecars(serverUrl: string) {
private async startAdaptersSidecars(serverUrl: string): Promise<void> {
const env = await this.resolveSidecarBaseEnv()
for (const [label, flag] of [
['feishu', '--feishu'],
['telegram', '--telegram'],
@ -106,6 +115,7 @@ export class ElectronServerRuntime {
h5DistDir: this.h5DistDir,
serverUrl,
flag,
env,
}))
this.captureLogs(child, `claude-adapters:${label}`)
this.adapters.push(child)
@ -140,4 +150,24 @@ export class ElectronServerRuntime {
if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`)
})
}
private async resolveSidecarBaseEnv(): Promise<NodeJS.ProcessEnv> {
this.sidecarEnvPromise ??= this.resolveSidecarBaseEnvOnce()
return await this.sidecarEnvPromise
}
private async resolveSidecarBaseEnvOnce(): Promise<NodeJS.ProcessEnv> {
if (!this.resolveSystemProxy) return process.env
try {
const rules = await this.resolveSystemProxy('https://auth.openai.com/')
return mergeProxyEnv(
process.env,
proxyUrlFromElectronProxyRules(rules),
)
} catch (error) {
console.error('[desktop] failed to resolve system proxy for sidecars', error)
return process.env
}
}
}

View File

@ -7,6 +7,8 @@ import {
createAdapterPlan,
createServerPlan,
httpToWebSocketUrl,
mergeProxyEnv,
proxyUrlFromElectronProxyRules,
pushStartupLog,
resolveHostTriple,
} from './sidecarManager'
@ -75,6 +77,29 @@ 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')
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')
})
it('does not override explicit sidecar proxy environment', () => {
const env = mergeProxyEnv(
{ HTTPS_PROXY: 'http://manual.example:8080' },
'http://system.example:8080',
)
expect(env).toEqual({ HTTPS_PROXY: 'http://manual.example:8080' })
})
it('keeps startup logs bounded', () => {
const logs: string[] = []
for (let index = 0; index < 85; index++) {

View File

@ -16,6 +16,13 @@ export type SidecarPlan = {
env: NodeJS.ProcessEnv
}
const PROXY_ENV_KEYS = [
'HTTP_PROXY',
'HTTPS_PROXY',
'http_proxy',
'https_proxy',
] as const
export function resolveHostTriple(platform = process.platform, arch = process.arch): string {
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin'
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin'
@ -96,6 +103,43 @@ 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 mergeProxyEnv(
baseEnv: NodeJS.ProcessEnv,
proxyUrl: string | undefined,
): NodeJS.ProcessEnv {
if (!proxyUrl) return baseEnv
if (PROXY_ENV_KEYS.some(key => baseEnv[key])) return baseEnv
return {
...baseEnv,
HTTP_PROXY: proxyUrl,
HTTPS_PROXY: proxyUrl,
http_proxy: proxyUrl,
https_proxy: proxyUrl,
NO_PROXY: baseEnv.NO_PROXY || baseEnv.no_proxy || 'localhost,127.0.0.1,::1',
}
}
export function buildSidecarEnv(baseEnv: NodeJS.ProcessEnv, h5DistDir: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...baseEnv,

View File

@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import {
captureWindowState,
hideWindowSafely,
hasMeaningfulIntersection,
installWindowLifecycle,
isPersistableWindowState,
@ -11,6 +12,7 @@ import {
readWindowState,
restoreWindowMaximized,
showMainWindow,
toggleWindowFullScreen,
windowOptionsFromState,
windowStatePath,
writeWindowState,
@ -122,6 +124,8 @@ describe('Electron window service', () => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isSimpleFullScreen: () => false,
isFullScreen: () => false,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
@ -141,6 +145,115 @@ describe('Electron window service', () => {
}
})
it('exits fullscreen before hiding on close to avoid a black macOS fullscreen Space', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-fullscreen-close-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const onceHandlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
hide: vi.fn(),
setFullScreen: vi.fn(),
isDestroyed: () => false,
isSimpleFullScreen: () => false,
isFullScreen: () => true,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => false,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('runs follow-up hide actions only after fullscreen has been left', () => {
const onceHandlers = new Map<string, (...args: never[]) => void>()
const afterHide = vi.fn()
const window = {
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
hide: vi.fn(),
setFullScreen: vi.fn(),
isDestroyed: () => false,
isSimpleFullScreen: () => false,
isFullScreen: () => true,
}
hideWindowSafely(window as never, afterHide)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
expect(afterHide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
expect(afterHide).toHaveBeenCalledTimes(1)
})
it('hides immediately after leaving simple fullscreen because it does not create a macOS Space', () => {
const afterHide = vi.fn()
const window = {
isSimpleFullScreen: () => true,
setSimpleFullScreen: vi.fn(),
hide: vi.fn(),
}
hideWindowSafely(window as never, afterHide)
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).toHaveBeenCalledTimes(1)
expect(afterHide).toHaveBeenCalledTimes(1)
})
it('toggles simple fullscreen on macOS instead of native fullscreen Spaces', () => {
const window = {
isSimpleFullScreen: () => false,
setSimpleFullScreen: vi.fn(),
isFullScreen: vi.fn(),
setFullScreen: vi.fn(),
}
toggleWindowFullScreen(window as never, 'darwin')
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(true)
expect(window.setFullScreen).not.toHaveBeenCalled()
})
it('toggles native fullscreen on non-macOS platforms', () => {
const window = {
isSimpleFullScreen: vi.fn(),
setSimpleFullScreen: vi.fn(),
isFullScreen: () => false,
setFullScreen: vi.fn(),
}
toggleWindowFullScreen(window as never, 'linux')
expect(window.setFullScreen).toHaveBeenCalledWith(true)
expect(window.setSimpleFullScreen).not.toHaveBeenCalled()
})
it('allows the window to close normally once the app is explicitly quitting', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-quit-'))
try {

View File

@ -116,6 +116,37 @@ export function saveWindowState(app: App, window: BrowserWindow) {
if (state) writeWindowState(app, state)
}
export function hideWindowSafely(window: BrowserWindow, afterHide?: () => void) {
if (window.isSimpleFullScreen()) {
window.setSimpleFullScreen(false)
window.hide()
afterHide?.()
return
}
if (!window.isFullScreen()) {
window.hide()
afterHide?.()
return
}
window.once('leave-full-screen', () => {
if (!window.isDestroyed()) {
window.hide()
afterHide?.()
}
})
window.setFullScreen(false)
}
export function toggleWindowFullScreen(window: BrowserWindow, platform = process.platform) {
if (platform === 'darwin') {
window.setSimpleFullScreen(!window.isSimpleFullScreen())
return
}
window.setFullScreen(!window.isFullScreen())
}
export function showMainWindow(window: BrowserWindow | null) {
if (!window) return
if (!window.isVisible()) window.show()
@ -136,7 +167,7 @@ export function installWindowLifecycle({
saveWindowState(app, window)
if (shouldQuit()) return
event.preventDefault()
window.hide()
hideWindowSafely(window)
})
window.on('move', () => saveWindowState(app, window))

View File

@ -361,7 +361,8 @@ describe('Settings > General tab', () => {
expect(screen.getByText('Enter an HTTP or HTTPS proxy URL.')).toBeInTheDocument()
expect(saveButton).toBeDisabled()
fireEvent.change(proxyInput, { target: { value: ' http://127.0.0.1:7890 ' } })
fireEvent.change(proxyInput, { target: { value: ' http://user:p%40ss@127.0.0.1:7890 ' } })
expect(screen.getByText('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.')).toBeInTheDocument()
const timeoutInput = screen.getByLabelText('AI request timeout')
expect(timeoutInput).toHaveAttribute('type', 'number')
expect(screen.queryByRole('slider', { name: 'AI request timeout' })).not.toBeInTheDocument()
@ -376,7 +377,7 @@ describe('Settings > General tab', () => {
aiRequestTimeoutMs: 180_000,
proxy: {
mode: 'manual',
url: 'http://127.0.0.1:7890',
url: 'http://user:p%40ss@127.0.0.1:7890',
},
})
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({

View File

@ -983,7 +983,7 @@ export const en = {
'settings.general.networkProxyModeManual': 'Manual proxy',
'settings.general.networkProxyModeManualDescription': 'Use the HTTP or HTTPS proxy URL entered below.',
'settings.general.networkProxyUrl': 'Proxy URL',
'settings.general.networkProxyUrlHint': 'HTTP and HTTPS proxy URLs are supported, for example http://127.0.0.1:7890.',
'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.',
'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.',
'settings.general.networkTimeout': 'AI request timeout',

View File

@ -985,7 +985,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.networkProxyModeManual': '手动代理',
'settings.general.networkProxyModeManualDescription': '使用下方填写的 HTTP 或 HTTPS 代理地址。',
'settings.general.networkProxyUrl': '代理地址',
'settings.general.networkProxyUrlHint': '支持 HTTP 和 HTTPS 代理,例如 http://127.0.0.1:7890。',
'settings.general.networkProxyUrlHint': '支持 HTTP 和 HTTPS 代理。需要认证时可填写 http://user:password@127.0.0.1:7890该 URL 会随网络设置保存。',
'settings.general.networkProxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。',
'settings.general.networkProxyUrlRequired': '请输入代理地址。',
'settings.general.networkTimeout': 'AI 请求超时',

View File

@ -12,6 +12,7 @@ import {
getHahaOpenAIOAuthFilePath,
type StoredOpenAIOAuthTokens,
} from '../services/hahaOpenAIOAuthService.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
let tmpDir: string
let originalConfigDir: string | undefined
@ -75,6 +76,7 @@ async function setup() {
)
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
resetSettingsCache()
callbackPort = await getFreePort()
service = new HahaOpenAIOAuthService({ callbackPort })
}
@ -86,6 +88,7 @@ async function teardown() {
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
resetSettingsCache()
await fs.rm(tmpDir, { recursive: true, force: true })
}
@ -250,6 +253,53 @@ describe('HahaOpenAIOAuthService — session management', () => {
}
})
test('callback listener uses saved manual network proxy for token exchange', async () => {
const originalFetch = globalThis.fetch
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: 45_000,
proxy: {
mode: 'manual',
url: ' http://127.0.0.1:7890 ',
},
},
}),
'utf-8',
)
resetSettingsCache()
const session = await service.startSession({ serverPort: 54321 })
let tokenRequestInit: RequestInit | undefined
globalThis.fetch = (async (_url, init) => {
tokenRequestInit = init
return new Response(
JSON.stringify({
access_token: 'openai-access-token',
refresh_token: 'openai-refresh-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) 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:7890',
)
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
} finally {
globalThis.fetch = originalFetch
}
})
test('callback listener renders an error page when token exchange fails', async () => {
const originalFetch = globalThis.fetch
const session = await service.startSession({ serverPort: 54321 })

View File

@ -98,4 +98,21 @@ describe('network settings', () => {
https_proxy: 'http://127.0.0.1:7890',
})
})
it('preserves authenticated manual proxy URLs for provider requests', () => {
const settings = normalizeNetworkSettings({
network: {
proxy: {
mode: 'manual',
url: ' https://user:p%40ss@proxy.example.com:8443 ',
},
},
})
expect(getManualNetworkProxyUrl(settings)).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',
})
})
})

View File

@ -24,8 +24,13 @@ import {
withRefreshedAccessToken,
OPENAI_CODEX_REDIRECT_PATH,
OPENAI_CODEX_OAUTH_PORT,
type OpenAITokenFetchOptions,
} from '../../services/openaiAuth/client.js'
import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js'
import {
getManualNetworkProxyUrl,
loadNetworkSettings,
} from './networkSettings.js'
export type StoredOpenAIOAuthTokens = {
accessToken: string
@ -49,6 +54,7 @@ export type OpenAIOAuthSession = {
type OpenAIRefreshFn = (
refreshToken: string,
options?: OpenAITokenFetchOptions,
) => Promise<OpenAIOAuthTokenResponse>
const SESSION_TTL_MS = 5 * 60 * 1000
@ -289,6 +295,7 @@ export class HahaOpenAIOAuthService {
code: authorizationCode,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
...(await this.getOpenAITokenFetchOptions()),
})
const normalized = normalizeOpenAITokens(response)
@ -316,7 +323,10 @@ export class HahaOpenAIOAuthService {
if (!tokens.refreshToken) return null
try {
const refreshed = await this.refreshFn(tokens.refreshToken)
const refreshed = await this.refreshFn(
tokens.refreshToken,
await this.getOpenAITokenFetchOptions(),
)
const normalized = withRefreshedAccessToken(
{
accessToken: tokens.accessToken,
@ -353,6 +363,14 @@ export class HahaOpenAIOAuthService {
const tokens = await this.ensureFreshTokens()
return tokens?.accessToken ?? null
}
private async getOpenAITokenFetchOptions(): Promise<OpenAITokenFetchOptions> {
const networkSettings = await loadNetworkSettings()
return {
proxyUrl: getManualNetworkProxyUrl(networkSettings),
timeoutMs: networkSettings.aiRequestTimeoutMs,
}
}
}
export const hahaOpenAIOAuthService = new HahaOpenAIOAuthService()

View File

@ -89,6 +89,40 @@ describe('OpenAI Codex OAuth client', () => {
}
})
test('exchanges authorization code through configured proxy fetch options', async () => {
const originalFetch = globalThis.fetch
let tokenRequestInit: RequestInit | undefined
globalThis.fetch = (async (_input, init) => {
tokenRequestInit = init
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await exchangeOpenAICodeForTokens({
code: 'auth-code',
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'verifier',
proxyUrl: 'http://127.0.0.1:7890',
timeoutMs: 30_000,
})
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
'http://127.0.0.1:7890',
)
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
} finally {
globalThis.fetch = originalFetch
}
})
test('refreshes tokens with Codex-compatible token request headers', async () => {
const originalFetch = globalThis.fetch
let tokenRequestBody = ''
@ -121,6 +155,36 @@ describe('OpenAI Codex OAuth client', () => {
}
})
test('refreshes tokens through configured proxy fetch options', async () => {
const originalFetch = globalThis.fetch
let tokenRequestInit: RequestInit | undefined
globalThis.fetch = (async (_input, init) => {
tokenRequestInit = init
return new Response(
JSON.stringify({
access_token: 'access-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await refreshOpenAITokens('refresh-token', {
proxyUrl: 'http://127.0.0.1:7890',
timeoutMs: 30_000,
})
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
'http://127.0.0.1:7890',
)
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
} finally {
globalThis.fetch = originalFetch
}
})
test('includes sanitized token error response details for diagnostics', async () => {
const originalFetch = globalThis.fetch

View File

@ -5,6 +5,7 @@ import type {
OpenAIOAuthTokenResponse,
OpenAIOAuthTokens,
} from './types.js'
import { getProxyFetchOptions } from '../../utils/proxy.js'
export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com'
export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
@ -23,6 +24,22 @@ const OPENAI_TOKEN_REQUEST_HEADERS = {
'User-Agent': OPENAI_CODEX_TOKEN_USER_AGENT,
} as const
export type OpenAITokenFetchOptions = {
proxyUrl?: string | null
timeoutMs?: number
}
function buildOpenAITokenFetchInit(
init: RequestInit,
options: OpenAITokenFetchOptions = {},
): RequestInit {
return {
...init,
...(options.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}),
...getProxyFetchOptions({ proxyUrl: options.proxyUrl }),
}
}
export function generateOpenAIState(): string {
return randomBytes(32).toString('hex')
}
@ -55,18 +72,26 @@ export async function exchangeOpenAICodeForTokens(input: {
code: string
redirectUri: string
codeVerifier: string
proxyUrl?: string | null
timeoutMs?: number
}): Promise<OpenAIOAuthTokenResponse> {
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'authorization_code',
code: input.code,
redirect_uri: input.redirectUri,
client_id: OPENAI_CODEX_CLIENT_ID,
code_verifier: input.codeVerifier,
}).toString(),
})
const response = await fetch(
`${OPENAI_AUTH_ISSUER}/oauth/token`,
buildOpenAITokenFetchInit(
{
method: 'POST',
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'authorization_code',
code: input.code,
redirect_uri: input.redirectUri,
client_id: OPENAI_CODEX_CLIENT_ID,
code_verifier: input.codeVerifier,
}).toString(),
},
input,
),
)
if (!response.ok) {
throw await buildOpenAITokenHttpError('exchange', response)
@ -77,17 +102,24 @@ export async function exchangeOpenAICodeForTokens(input: {
export async function refreshOpenAITokens(
refreshToken: string,
options: OpenAITokenFetchOptions = {},
): Promise<OpenAIOAuthTokenResponse> {
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OPENAI_CODEX_CLIENT_ID,
scope: 'openid profile email',
}).toString(),
})
const response = await fetch(
`${OPENAI_AUTH_ISSUER}/oauth/token`,
buildOpenAITokenFetchInit(
{
method: 'POST',
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OPENAI_CODEX_CLIENT_ID,
scope: 'openid profile email',
}).toString(),
},
options,
),
)
if (!response.ok) {
throw await buildOpenAITokenHttpError('refresh', response)