diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 19601308..d2cdefce 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -81,6 +81,10 @@ vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ ClaudeOfficialLogin: () =>
, })) +vi.mock('../components/settings/ChatGPTOfficialLogin', () => ({ + ChatGPTOfficialLogin: () =>
, +})) + vi.mock('../pages/AdapterSettings', () => ({ AdapterSettings: () =>
Adapter Settings Mock
, })) @@ -913,6 +917,16 @@ describe('Settings > Providers tab', () => { expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() }) + it('does not query ChatGPT OAuth status before providers finish loading', () => { + providerStoreState.providers = [] + providerStoreState.activeId = 'openai-official' + providerStoreState.hasLoadedProviders = false + + render() + + expect(screen.queryByTestId('chatgpt-official-login')).not.toBeInTheDocument() + }) + it('shows official OAuth status only after official provider is confirmed active', () => { providerStoreState.providers = [] providerStoreState.activeId = null @@ -923,6 +937,20 @@ describe('Settings > Providers tab', () => { expect(screen.getByTestId('claude-official-login')).toBeInTheDocument() }) + it('shows ChatGPT Official as the active built-in provider', () => { + providerStoreState.providers = [] + providerStoreState.activeId = 'openai-official' + providerStoreState.hasLoadedProviders = true + + render() + + const openAIProvider = screen.getByTestId('openai-official-provider') + expect(within(openAIProvider).getByText('ChatGPT Official')).toBeInTheDocument() + expect(within(openAIProvider).getByText('Default')).toBeInTheDocument() + expect(screen.getByTestId('chatgpt-official-login')).toBeInTheDocument() + expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() + }) + it('requires confirmation before deleting a provider', async () => { render() diff --git a/desktop/src/api/hahaOpenAIOAuth.ts b/desktop/src/api/hahaOpenAIOAuth.ts new file mode 100644 index 00000000..2aefbea6 --- /dev/null +++ b/desktop/src/api/hahaOpenAIOAuth.ts @@ -0,0 +1,38 @@ +// desktop/src/api/hahaOpenAIOAuth.ts + +import { api, getBaseUrl } from './client' + +export type HahaOpenAIOAuthStatus = + | { loggedIn: false } + | { + loggedIn: true + expiresAt: number | null + email: string | null + accountId: string | null + } + +function currentServerPort(): number { + const port = new URL(getBaseUrl()).port + const parsed = Number.parseInt(port, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`) + } + return parsed +} + +export const hahaOpenAIOAuthApi = { + start() { + return api.post<{ authorizeUrl: string; state: string }>( + '/api/haha-openai-oauth/start', + { serverPort: currentServerPort() }, + ) + }, + + status() { + return api.get('/api/haha-openai-oauth') + }, + + logout() { + return api.delete<{ ok: true }>('/api/haha-openai-oauth') + }, +} diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index 0a248f95..a99c9734 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -16,7 +16,7 @@ type PresetsResponse = { presets: ProviderPreset[] } type TestResultResponse = { result: ProviderTestResult } type AuthStatusResponse = { hasAuth: boolean - source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none' + source: 'cc-haha-provider' | 'openai-oauth' | 'original-settings' | 'env' | 'none' activeProvider?: string } diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 7c98d126..59f9e0ea 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -7,6 +7,7 @@ import { useChatStore } from '../../stores/chatStore' import { useProviderStore } from '../../stores/providerStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useSettingsStore } from '../../stores/settingsStore' +import { OPENAI_OFFICIAL_PROVIDER_ID } from '../../constants/openaiOfficialProvider' import type { ModelInfo } from '../../types/settings' const MODELS: ModelInfo[] = [ @@ -118,6 +119,56 @@ describe('ModelSelector', () => { }) }) + it('uses the ChatGPT Official catalog when that built-in provider is active', async () => { + const openAIModels: ModelInfo[] = [ + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + description: 'Best for coding and agentic work', + context: '', + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'Latest general-purpose model', + context: '', + }, + ] + const setSessionRuntime = vi.fn() + useSettingsStore.setState({ + locale: 'en', + availableModels: openAIModels, + currentModel: openAIModels[0], + activeProviderName: 'ChatGPT Official', + }) + useProviderStore.setState({ + providers: [], + activeId: OPENAI_OFFICIAL_PROVIDER_ID, + hasLoadedProviders: true, + isLoading: true, + }) + useChatStore.setState({ + setSessionRuntime, + } as Partial>) + + render() + + await clickByRole(/GPT-5\.3 Codex/i) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /GPT-5\.5/ })) + await Promise.resolve() + }) + + expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({ + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + }) + expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', { + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + }) + }) + it('portals the dropdown outside clipping containers and positions it below the trigger', async () => { useSettingsStore.setState({ locale: 'en', diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index a1a88014..b39ed9a7 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog' +import { + OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + OPENAI_OFFICIAL_MODELS, + OPENAI_OFFICIAL_PROVIDER_ID, +} from '../../constants/openaiOfficialProvider' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' import { useProviderStore } from '../../stores/providerStore' @@ -43,12 +48,17 @@ const VIEWPORT_MARGIN = 16 const DROPDOWN_MAX_HEIGHT = 420 const DROPDOWN_MIN_HEIGHT = 180 -function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice { +function officialChoices( + providerId: string | null, + models: ModelInfo[], + isDefault: boolean, + officialName: string, +): ProviderChoice { return { - providerId: null, + providerId, providerName: officialName, isDefault, - models: availableModels.length > 0 ? availableModels : OFFICIAL_MODELS, + models, } } @@ -89,10 +99,24 @@ function buildProviderChoices( activeId: string | null, availableModels: ModelInfo[], officialName: string, + openAIOfficialName: string, labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, ): ProviderChoice[] { + const claudeOfficialModels = activeId === null && availableModels.length > 0 + ? availableModels + : OFFICIAL_MODELS + const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0 + ? availableModels + : OPENAI_OFFICIAL_MODELS + return [ - officialChoices(availableModels, activeId === null, officialName), + officialChoices(null, claudeOfficialModels, activeId === null, officialName), + officialChoices( + OPENAI_OFFICIAL_PROVIDER_ID, + openAIOfficialModels, + activeId === OPENAI_OFFICIAL_PROVIDER_ID, + openAIOfficialName, + ), ...providers.map((provider) => ({ providerId: provider.id, providerName: provider.name, @@ -116,7 +140,11 @@ function resolveDefaultRuntimeSelection( return { providerId: inferredProviderId, - modelId: currentModelId ?? OFFICIAL_DEFAULT_MODEL_ID, + modelId: currentModelId ?? ( + inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID + ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID + : OFFICIAL_DEFAULT_MODEL_ID + ), } } @@ -258,8 +286,9 @@ export function ModelSelector({ () => buildProviderChoices( providers, activeId, - activeId === null ? availableModels : OFFICIAL_MODELS, + availableModels, t('settings.providers.officialName'), + t('settings.providers.openaiOfficialName'), roleLabels, ), [activeId, availableModels, providers, roleLabels, t], diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx new file mode 100644 index 00000000..d568f080 --- /dev/null +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +const { + copyTextToClipboardMock, + logoutMock, + shellOpenMock, + startMock, + statusMock, +} = vi.hoisted(() => ({ + copyTextToClipboardMock: vi.fn(), + logoutMock: vi.fn(), + shellOpenMock: vi.fn(), + startMock: vi.fn(), + statusMock: vi.fn(), +})) + +vi.mock('@tauri-apps/plugin-shell', () => ({ + open: shellOpenMock, +})) + +vi.mock('../../api/hahaOpenAIOAuth', () => ({ + hahaOpenAIOAuthApi: { + start: startMock, + status: statusMock, + logout: logoutMock, + }, +})) + +vi.mock('../chat/clipboard', () => ({ + copyTextToClipboard: copyTextToClipboardMock, +})) + +import { ChatGPTOfficialLogin } from './ChatGPTOfficialLogin' +import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' +import { useSettingsStore } from '../../stores/settingsStore' + +const initialOAuthState = useHahaOpenAIOAuthStore.getState() + +describe('ChatGPTOfficialLogin', () => { + beforeEach(() => { + vi.useRealTimers() + startMock.mockReset() + statusMock.mockReset() + logoutMock.mockReset() + shellOpenMock.mockReset() + copyTextToClipboardMock.mockReset() + useSettingsStore.setState({ locale: 'en' }) + useHahaOpenAIOAuthStore.setState({ + ...initialOAuthState, + status: null, + isPolling: false, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + act(() => { + useHahaOpenAIOAuthStore.getState().stopPolling() + useHahaOpenAIOAuthStore.setState(initialOAuthState) + }) + vi.useRealTimers() + cleanup() + vi.restoreAllMocks() + }) + + it('keeps an actionable authorization link when shell open fails', async () => { + const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state' + statusMock.mockResolvedValue({ loggedIn: false }) + startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' }) + shellOpenMock.mockRejectedValue(new Error('shell unavailable')) + copyTextToClipboardMock.mockResolvedValue(true) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + await screen.findByRole('button', { name: 'Sign in with ChatGPT' }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' })) + }) + + expect(shellOpenMock).toHaveBeenCalledWith(authorizeUrl) + expect(consoleErrorSpy).toHaveBeenCalledWith('[ChatGPTOfficialLogin] shellOpen failed:', expect.any(Error)) + expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' })) + }) + + expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl) + expect(useHahaOpenAIOAuthStore.getState().error).toBeNull() + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true) + expect(screen.queryByText(/Unable to open browser/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Copy authorization link' })).not.toBeInTheDocument() + }) + + it('keeps the authorization link available when copy fails', async () => { + const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state' + statusMock.mockResolvedValue({ loggedIn: false }) + startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' }) + shellOpenMock.mockRejectedValue(new Error('shell unavailable')) + copyTextToClipboardMock.mockResolvedValue(false) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + await screen.findByRole('button', { name: 'Sign in with ChatGPT' }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' })) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' })) + }) + + expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + expect(screen.getByText(/Unable to copy authorization link/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy authorization link' })).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx new file mode 100644 index 00000000..087596b3 --- /dev/null +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx @@ -0,0 +1,144 @@ +// desktop/src/components/settings/ChatGPTOfficialLogin.tsx + +import { useEffect, useState } from 'react' +import { open as shellOpen } from '@tauri-apps/plugin-shell' +import { Copy, LogIn, LogOut } from 'lucide-react' +import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' +import { useTranslation } from '../../i18n' +import { copyTextToClipboard } from '../chat/clipboard' + +export function ChatGPTOfficialLogin() { + const t = useTranslation() + const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState(null) + const { + status, + isLoading, + error, + fetchStatus, + login, + logout, + startPolling, + stopPolling, + } = useHahaOpenAIOAuthStore() + + useEffect(() => { + void fetchStatus() + return () => stopPolling() + }, [fetchStatus, stopPolling]) + + useEffect(() => { + if (status?.loggedIn) { + setManualAuthorizeUrl(null) + } + }, [status?.loggedIn]) + + const handleLogin = async () => { + setManualAuthorizeUrl(null) + try { + const { authorizeUrl } = await login() + setManualAuthorizeUrl(authorizeUrl) + try { + await shellOpen(authorizeUrl) + setManualAuthorizeUrl(null) + startPolling() + } catch (err) { + console.error('[ChatGPTOfficialLogin] shellOpen failed:', err) + useHahaOpenAIOAuthStore.setState({ + error: t('settings.chatgptOfficialLogin.openBrowserFailed'), + }) + } + } catch { + // store.login() errors are already captured into store.error + } + } + + const handleCopyAuthorizeUrl = async () => { + if (!manualAuthorizeUrl) return + const copied = await copyTextToClipboard(manualAuthorizeUrl) + if (copied) { + setManualAuthorizeUrl(null) + useHahaOpenAIOAuthStore.setState({ error: null }) + startPolling() + return + } + useHahaOpenAIOAuthStore.setState({ + error: t('settings.chatgptOfficialLogin.copyLinkFailed'), + }) + } + + const manualAuthorizeButton = manualAuthorizeUrl ? ( + + ) : null + + if (status === null) { + if (error) { + return ( +
+
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ {manualAuthorizeButton} +
+ ) + } + return ( +
+ {t('common.loading')} +
+ ) + } + + if (status.loggedIn) { + const accountLabel = status.email || status.accountId || t('settings.chatgptOfficialLogin.accountUnknown') + return ( +
+ + {t('settings.chatgptOfficialLogin.loggedInPrefix')} {accountLabel} + + +
+ ) + } + + return ( +
+
+ {t('settings.chatgptOfficialLogin.intro')} +
+ + {error && ( +
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ )} + {manualAuthorizeButton} +
+ ) +} diff --git a/desktop/src/constants/openaiOfficialProvider.ts b/desktop/src/constants/openaiOfficialProvider.ts new file mode 100644 index 00000000..d38947e1 --- /dev/null +++ b/desktop/src/constants/openaiOfficialProvider.ts @@ -0,0 +1,32 @@ +import type { ModelInfo } from '../types/settings' + +export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' +export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex' +export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official' + +export const OPENAI_OFFICIAL_MODELS: ModelInfo[] = [ + { + id: OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + name: 'GPT-5.3 Codex', + description: 'Best for coding and agentic work', + context: '', + }, + { + id: 'gpt-5.4', + name: 'GPT-5.4', + description: 'Strong general-purpose model', + context: '', + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'Latest general-purpose model', + context: '', + }, + { + id: 'gpt-5.4-mini', + name: 'GPT-5.4 Mini', + description: 'Fastest for quick tasks', + context: '', + }, +] diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index c7b2b822..2d543f76 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -273,12 +273,27 @@ export const en = { 'settings.claudeOfficialLogin.errorPrefix': 'Error: ', 'settings.claudeOfficialLogin.openBrowserFailed': 'Failed to open browser; please visit the authorization URL manually.', + // Settings > ChatGPT Official Login + 'settings.chatgptOfficialLogin.intro': 'Sign in with ChatGPT to use GPT models from desktop sessions.', + 'settings.chatgptOfficialLogin.loginButton': 'Sign in with ChatGPT', + 'settings.chatgptOfficialLogin.loginStarting': 'Starting sign-in...', + 'settings.chatgptOfficialLogin.logoutButton': 'Sign out', + 'settings.chatgptOfficialLogin.logoutProcessing': 'Signing out...', + 'settings.chatgptOfficialLogin.loggedInPrefix': 'Signed in as', + 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT account', + 'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization link and open it manually.', + 'settings.chatgptOfficialLogin.copyAuthorizeUrl': 'Copy authorization link', + 'settings.chatgptOfficialLogin.copyLinkFailed': 'Unable to copy authorization link.', + 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth error: ', + // Settings > Providers 'settings.providers.title': 'Providers', 'settings.providers.description': 'Manage API providers for model access.', 'settings.providers.addProvider': 'Add Provider', 'settings.providers.officialName': 'Claude Official', 'settings.providers.officialDesc': 'Anthropic native — no API key required', + 'settings.providers.openaiOfficialName': 'ChatGPT Official', + 'settings.providers.openaiOfficialDesc': 'OpenAI OAuth via your ChatGPT account — no API key required', 'settings.providers.connected': 'Connected ({latency}ms)', 'settings.providers.failed': 'Failed: {error}', 'settings.providers.connectivityOk': '① Connectivity ({latency}ms)', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index d34a1546..79e67afd 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -275,12 +275,27 @@ export const zh: Record = { 'settings.claudeOfficialLogin.errorPrefix': '错误:', 'settings.claudeOfficialLogin.openBrowserFailed': '无法打开浏览器,请手动访问授权链接。', + // Settings > ChatGPT Official Login + 'settings.chatgptOfficialLogin.intro': '登录 ChatGPT 后,就可以在桌面端会话里使用 GPT 模型。', + 'settings.chatgptOfficialLogin.loginButton': '登录 ChatGPT', + 'settings.chatgptOfficialLogin.loginStarting': '正在启动登录...', + 'settings.chatgptOfficialLogin.logoutButton': '退出登录', + 'settings.chatgptOfficialLogin.logoutProcessing': '正在退出...', + 'settings.chatgptOfficialLogin.loggedInPrefix': '已登录', + 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT 账号', + 'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请复制授权链接并手动打开。', + 'settings.chatgptOfficialLogin.copyAuthorizeUrl': '复制授权链接', + 'settings.chatgptOfficialLogin.copyLinkFailed': '无法复制授权链接。', + 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:', + // Settings > Providers 'settings.providers.title': '服务商', 'settings.providers.description': '管理 API 服务商以访问模型。', 'settings.providers.addProvider': '添加服务商', 'settings.providers.officialName': 'Claude 官方', 'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥', + 'settings.providers.openaiOfficialName': 'ChatGPT 官方', + 'settings.providers.openaiOfficialDesc': '通过 ChatGPT 账号完成 OpenAI OAuth — 无需 API 密钥', 'settings.providers.connected': '已连接 ({latency}ms)', 'settings.providers.failed': '失败: {error}', 'settings.providers.connectivityOk': '① 连通 ({latency}ms)', diff --git a/desktop/src/lib/__tests__/providerSettingsJson.test.ts b/desktop/src/lib/__tests__/providerSettingsJson.test.ts index 94c47807..6bd9795d 100644 --- a/desktop/src/lib/__tests__/providerSettingsJson.test.ts +++ b/desktop/src/lib/__tests__/providerSettingsJson.test.ts @@ -52,6 +52,8 @@ describe('provider settings JSON helpers', () => { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_MODEL: 'old-model', CLAUDE_CODE_MODEL_CONTEXT_WINDOWS: '{"old":100000}', + CC_HAHA_OPENAI_OAUTH_PROVIDER: '1', + OPENAI_CODEX_OAUTH_FILE: '/tmp/openai-oauth.json', CC_HAHA_SEND_DISABLED_THINKING: '1', USER_DEFINED: 'keep-me', }, diff --git a/desktop/src/lib/providerSettingsJson.ts b/desktop/src/lib/providerSettingsJson.ts index 9ad5fc09..e76e79ba 100644 --- a/desktop/src/lib/providerSettingsJson.ts +++ b/desktop/src/lib/providerSettingsJson.ts @@ -20,9 +20,11 @@ const PROVIDER_SETTINGS_JSON_ENV_KEYS = new Set([ 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME', 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_SMALL_FAST_MODEL', + 'CC_HAHA_OPENAI_OAUTH_PROVIDER', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', 'CLAUDE_CODE_SUBAGENT_MODEL', + 'OPENAI_CODEX_OAUTH_FILE', ]) function getEnvRecord(raw: string): Record | null { diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 3aee9958..9a150a7d 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -32,6 +32,8 @@ import { ActivitySettings } from './ActivitySettings' import { MemorySettings } from './MemorySettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' +import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin' +import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' import { isTauriRuntime } from '../lib/desktopRuntime' @@ -216,7 +218,8 @@ function ProviderSettings() { await fetchSettings() } - const isOfficialActive = hasLoadedProviders && activeId === null + const isClaudeOfficialActive = hasLoadedProviders && activeId === null + const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID return (
@@ -233,21 +236,22 @@ function ProviderSettings() { {/* Official provider — always visible at top */}
!isOfficialActive && handleActivateOfficial()} + onClick={() => !isClaudeOfficialActive && handleActivateOfficial()} > - +
{t('settings.providers.officialName')} - {isOfficialActive && ( + {isClaudeOfficialActive && ( {t('settings.providers.default')} )}
@@ -255,13 +259,44 @@ function ProviderSettings() {
- {isOfficialActive && ( + {isClaudeOfficialActive && (
)}
+
+
!isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)} + > + +
+
+ {t('settings.providers.openaiOfficialName')} + {isOpenAIOfficialActive && ( + {t('settings.providers.default')} + )} +
+
{t('settings.providers.openaiOfficialDesc')}
+
+
+ + {isOpenAIOfficialActive && ( +
+ +
+ )} +
+ {/* Saved providers */} {isLoading && providers.length === 0 ? (
diff --git a/desktop/src/stores/hahaOpenAIOAuthStore.test.ts b/desktop/src/stores/hahaOpenAIOAuthStore.test.ts new file mode 100644 index 00000000..5b6475dd --- /dev/null +++ b/desktop/src/stores/hahaOpenAIOAuthStore.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { startMock, statusMock, logoutMock } = vi.hoisted(() => ({ + startMock: vi.fn(), + statusMock: vi.fn(), + logoutMock: vi.fn(), +})) + +vi.mock('../api/hahaOpenAIOAuth', () => ({ + hahaOpenAIOAuthApi: { + start: startMock, + status: statusMock, + logout: logoutMock, + }, +})) + +import { useHahaOpenAIOAuthStore } from './hahaOpenAIOAuthStore' + +const initialState = useHahaOpenAIOAuthStore.getState() + +describe('hahaOpenAIOAuthStore', () => { + beforeEach(() => { + vi.useFakeTimers() + startMock.mockReset() + statusMock.mockReset() + logoutMock.mockReset() + useHahaOpenAIOAuthStore.setState({ + ...initialState, + status: null, + isPolling: false, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + useHahaOpenAIOAuthStore.getState().stopPolling() + useHahaOpenAIOAuthStore.setState(initialState) + vi.useRealTimers() + }) + + it('login returns authorizeUrl without starting polling', async () => { + startMock.mockResolvedValue({ + authorizeUrl: 'http://localhost:3456/callback/openai?state=openai-state', + state: 'openai-state', + }) + + const result = await useHahaOpenAIOAuthStore.getState().login() + + expect(result.authorizeUrl).toContain('/callback/openai') + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + }) + + it('startPolling stops after OpenAI OAuth status becomes logged in', async () => { + statusMock + .mockResolvedValueOnce({ loggedIn: false }) + .mockResolvedValueOnce({ + loggedIn: true, + expiresAt: Date.now() + 60_000, + email: 'user@example.com', + accountId: 'acct_123', + }) + + useHahaOpenAIOAuthStore.getState().startPolling() + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true) + + await vi.advanceTimersByTimeAsync(2_000) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true) + + await vi.advanceTimersByTimeAsync(2_000) + expect(useHahaOpenAIOAuthStore.getState().status).toMatchObject({ + loggedIn: true, + email: 'user@example.com', + accountId: 'acct_123', + }) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + }) + + it('logout clears status and stops polling', async () => { + logoutMock.mockResolvedValue({ ok: true }) + useHahaOpenAIOAuthStore.setState({ + status: { + loggedIn: true, + expiresAt: Date.now() + 60_000, + email: 'user@example.com', + accountId: 'acct_123', + }, + }) + useHahaOpenAIOAuthStore.getState().startPolling() + + await useHahaOpenAIOAuthStore.getState().logout() + + expect(logoutMock).toHaveBeenCalledTimes(1) + expect(useHahaOpenAIOAuthStore.getState().status).toEqual({ loggedIn: false }) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + }) +}) diff --git a/desktop/src/stores/hahaOpenAIOAuthStore.ts b/desktop/src/stores/hahaOpenAIOAuthStore.ts new file mode 100644 index 00000000..044ff33d --- /dev/null +++ b/desktop/src/stores/hahaOpenAIOAuthStore.ts @@ -0,0 +1,101 @@ +// desktop/src/stores/hahaOpenAIOAuthStore.ts + +import { create } from 'zustand' +import { + hahaOpenAIOAuthApi, + type HahaOpenAIOAuthStatus, +} from '../api/hahaOpenAIOAuth' + +const POLL_INTERVAL_MS = 2_000 + +type HahaOpenAIOAuthState = { + status: HahaOpenAIOAuthStatus | null + isPolling: boolean + isLoading: boolean + error: string | null + + fetchStatus: () => Promise + login: () => Promise<{ authorizeUrl: string }> + logout: () => Promise + startPolling: () => void + stopPolling: () => void +} + +export const useHahaOpenAIOAuthStore = create((set, get) => { + let pollTimer: ReturnType | null = null + + return { + status: null, + isPolling: false, + isLoading: false, + error: null, + + fetchStatus: async () => { + try { + const status = await hahaOpenAIOAuthApi.status() + set({ status, error: null }) + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }) + } + }, + + login: async () => { + set({ isLoading: true, error: null }) + try { + const res = await hahaOpenAIOAuthApi.start() + set({ isLoading: false }) + return { authorizeUrl: res.authorizeUrl } + } catch (err) { + set({ + isLoading: false, + error: err instanceof Error ? err.message : String(err), + }) + throw err + } + }, + + logout: async () => { + get().stopPolling() + set({ isLoading: true, error: null }) + try { + await hahaOpenAIOAuthApi.logout() + set({ status: { loggedIn: false }, isLoading: false }) + } catch (err) { + set({ + isLoading: false, + error: err instanceof Error ? err.message : String(err), + }) + throw err + } + }, + + startPolling: () => { + if (pollTimer) return + set({ isPolling: true }) + + const scheduleNext = () => { + pollTimer = setTimeout(async () => { + pollTimer = null + await get().fetchStatus() + const cur = get().status + if (cur && cur.loggedIn) { + get().stopPolling() + return + } + if (get().isPolling) { + scheduleNext() + } + }, POLL_INTERVAL_MS) + } + scheduleNext() + }, + + stopPolling: () => { + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + set({ isPolling: false }) + }, + } +}) diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts index 0d533450..7d16a7c0 100644 --- a/desktop/src/stores/providerStore.test.ts +++ b/desktop/src/stores/providerStore.test.ts @@ -7,6 +7,8 @@ const { runtimeStoreState, setSessionRuntimeMock, setSelectionMock, + settingsSetModelMock, + settingsFetchAllMock, } = vi.hoisted(() => ({ providersApiMock: { list: vi.fn(), @@ -32,6 +34,8 @@ const { }, setSessionRuntimeMock: vi.fn(), setSelectionMock: vi.fn(), + settingsSetModelMock: vi.fn(), + settingsFetchAllMock: vi.fn(), })) vi.mock('../api/providers', () => ({ @@ -59,8 +63,8 @@ vi.mock('./sessionRuntimeStore', () => ({ vi.mock('./settingsStore', () => ({ useSettingsStore: { getState: () => ({ - setModel: vi.fn(), - fetchAll: vi.fn(), + setModel: settingsSetModelMock, + fetchAll: settingsFetchAllMock, }), }, })) @@ -147,4 +151,33 @@ describe('providerStore runtime refresh', () => { expect(setSelectionMock).not.toHaveBeenCalled() expect(setSessionRuntimeMock).not.toHaveBeenCalled() }) + + it('sets the OpenAI default model when activating built-in ChatGPT Official', async () => { + providersApiMock.activate.mockResolvedValue({ ok: true }) + providersApiMock.list.mockResolvedValue({ + providers: [], + activeId: 'openai-official', + }) + + const { useProviderStore } = await import('./providerStore') + await useProviderStore.getState().activateProvider('openai-official') + + expect(settingsSetModelMock).toHaveBeenCalledWith('gpt-5.3-codex') + expect(settingsFetchAllMock).toHaveBeenCalled() + }) + + it('sets the provider main model when activating a saved provider', async () => { + const provider = makeProvider() + providersApiMock.activate.mockResolvedValue({ ok: true }) + providersApiMock.list.mockResolvedValue({ + providers: [provider], + activeId: provider.id, + }) + + const { useProviderStore } = await import('./providerStore') + await useProviderStore.getState().activateProvider(provider.id) + + expect(settingsSetModelMock).toHaveBeenCalledWith('model-main') + expect(settingsFetchAllMock).toHaveBeenCalled() + }) }) diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 77c6624c..585ce6f9 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -6,6 +6,10 @@ import { useChatStore } from './chatStore' import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useSettingsStore } from './settingsStore' import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog' +import { + OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + OPENAI_OFFICIAL_PROVIDER_ID, +} from '../constants/openaiOfficialProvider' import type { SavedProvider, CreateProviderInput, @@ -145,12 +149,17 @@ export const useProviderStore = create((set, get) => ({ await get().fetchProviders() // 更新默认 provider 时,同步刷新默认 model,避免 settings.json 里残留 // 旧 provider 的 model id 导致默认选择指向不存在的模型。 - const provider = get().providers.find((p) => p.id === id) - if (provider) { - const settings = useSettingsStore.getState() - await settings.setModel(provider.models.main) + const settings = useSettingsStore.getState() + if (id === OPENAI_OFFICIAL_PROVIDER_ID) { + await settings.setModel(OPENAI_OFFICIAL_DEFAULT_MODEL_ID) await settings.fetchAll() + return } + + const provider = get().providers.find((p) => p.id === id) + if (!provider) return + await settings.setModel(provider.models.main) + await settings.fetchAll() }, activateOfficial: async () => { diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts index a5ae0dc2..8057d9ba 100644 --- a/desktop/src/types/provider.ts +++ b/desktop/src/types/provider.ts @@ -9,6 +9,8 @@ export type ProviderAuthStrategy = | 'dual_same_token' | 'dual_dummy' +export type ProviderRuntimeKind = 'anthropic_compatible' | 'openai_oauth' + export type ModelMapping = { main: string haiku: string @@ -26,6 +28,7 @@ export type SavedProvider = { authStrategy?: ProviderAuthStrategy baseUrl: string apiFormat: ApiFormat + runtimeKind?: ProviderRuntimeKind models: ModelMapping autoCompactWindow?: number modelContextWindows?: ModelContextWindows @@ -39,6 +42,7 @@ export type CreateProviderInput = { authStrategy?: ProviderAuthStrategy baseUrl: string apiFormat?: ApiFormat + runtimeKind?: ProviderRuntimeKind models: ModelMapping autoCompactWindow?: number modelContextWindows?: ModelContextWindows @@ -51,6 +55,7 @@ export type UpdateProviderInput = { authStrategy?: ProviderAuthStrategy baseUrl?: string apiFormat?: ApiFormat + runtimeKind?: ProviderRuntimeKind models?: ModelMapping autoCompactWindow?: number | null modelContextWindows?: ModelContextWindows | null diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index f5770fc6..281e1652 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -376,6 +376,49 @@ describe('ConversationService', () => { expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('forced-official-token') }) + test('buildChildEnv does not inject Claude OAuth when ChatGPT Official is active', async () => { + const providerService = new ProviderService() + await providerService.activateProvider('openai-official') + + const { hahaOAuthService } = await import('../services/hahaOAuthService.js') + await hahaOAuthService.saveTokens({ + accessToken: 'claude-oauth-token-that-must-not-be-used', + refreshToken: 'claude-refresh-token', + expiresAt: Date.now() + 30 * 60_000, + scopes: ['user:inference'], + subscriptionType: 'max', + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp')) as Record + + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env.ANTHROPIC_BASE_URL).toBeUndefined() + expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + }) + + test('buildChildEnv injects ChatGPT Official runtime env for session-scoped provider selection', async () => { + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp', undefined, { + providerId: 'openai-official', + })) as Record + + expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1') + expect(env.OPENAI_CODEX_OAUTH_FILE).toBe( + path.join(tmpDir, 'cc-haha', 'openai-oauth.json'), + ) + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex') + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4') + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') + expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env.ANTHROPIC_BASE_URL).toBeUndefined() + }) + test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) diff --git a/src/server/__tests__/haha-openai-oauth-api.test.ts b/src/server/__tests__/haha-openai-oauth-api.test.ts index 97699c83..3a605561 100644 --- a/src/server/__tests__/haha-openai-oauth-api.test.ts +++ b/src/server/__tests__/haha-openai-oauth-api.test.ts @@ -6,8 +6,11 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' +import { createServer } from 'net' import { handleHahaOpenAIOAuthApi } from '../api/haha-openai-oauth.js' import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js' +import { startServer } from '../index.js' +import { ProviderService } from '../services/providerService.js' let tmpDir: string let originalConfigDir: string | undefined @@ -44,6 +47,22 @@ function buildReq( return { req, url, segments } } +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate test port'))) + return + } + const port = address.port + server.close(() => resolve(port)) + }) + }) +} + describe('POST /api/haha-openai-oauth/start', () => { beforeEach(setup) afterEach(teardown) @@ -157,3 +176,24 @@ describe('DELETE /api/haha-openai-oauth', () => { expect(await hahaOpenAIOAuthService.loadTokens()).toBeNull() }) }) + +describe('GET /auth/callback', () => { + beforeEach(setup) + afterEach(teardown) + + test('routes the OpenAI Codex redirect path to the desktop callback page', async () => { + const port = await getFreePort() + const originalServerPort = ProviderService.getServerPort() + const server = startServer(port, '127.0.0.1') + try { + const res = await fetch(`http://127.0.0.1:${port}/auth/callback`) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('OpenAI Login Failed') + expect(html).toContain('Missing code or state parameter') + } finally { + server.stop(true) + ProviderService.setServerPort(originalServerPort) + } + }) +}) diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index 505d9ad9..8bd331b7 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -2,12 +2,13 @@ * Unit tests for HahaOpenAIOAuthService — haha 自管 OpenAI OAuth 的核心 service 层。 */ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { HahaOpenAIOAuthService, + getHahaOpenAIOAuthFilePath, type StoredOpenAIOAuthTokens, } from '../services/hahaOpenAIOAuthService.js' @@ -46,12 +47,14 @@ describe('HahaOpenAIOAuthService — file storage', () => { accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.mock-access', refreshToken: 'eyJhbGciOiJSUzI1NiJ9.mock-refresh', expiresAt: Date.now() + 3600_000, + idToken: 'mock-id-token', email: 'test@example.com', accountId: 'acct_123', + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', } await service.saveTokens(tokens) - const oauthPath = path.join(tmpDir, 'cc-haha', 'openai-oauth.json') + const oauthPath = getHahaOpenAIOAuthFilePath() const stat = await fs.stat(oauthPath) if (process.platform !== 'win32') { expect(stat.mode & 0o777).toBe(0o600) @@ -72,6 +75,36 @@ describe('HahaOpenAIOAuthService — file storage', () => { await service.deleteTokens() expect(await service.loadTokens()).toBeNull() }) + + test('saveTokens cleans up tmp file when rename fails', async () => { + const renameSpy = spyOn(fs, 'rename').mockImplementation(async () => { + const error = new Error('rename failed') as NodeJS.ErrnoException + error.code = 'EXDEV' + throw error + }) + + try { + await expect( + service.saveTokens({ + accessToken: 'sensitive-access', + refreshToken: 'sensitive-refresh', + expiresAt: Date.now() + 3600_000, + idToken: 'sensitive-id-token', + email: 'test@example.com', + accountId: 'acct_123', + }), + ).rejects.toThrow('rename failed') + } finally { + renameSpy.mockRestore() + } + + const oauthPath = getHahaOpenAIOAuthFilePath() + const files = await fs.readdir(path.dirname(oauthPath)) + expect( + files.filter((name) => name.startsWith('openai-oauth.json.tmp.')), + ).toEqual([]) + expect(await service.loadTokens()).toBeNull() + }) }) describe('HahaOpenAIOAuthService — session management', () => { @@ -167,6 +200,31 @@ describe('HahaOpenAIOAuthService — ensureFreshAccessToken', () => { expect(loaded?.accessToken).toBe('new-fresh-token') }) + test('preserves existing refresh token and id token when refresh omits them', async () => { + await service.saveTokens({ + accessToken: 'expired', + refreshToken: 'refresh-to-preserve', + expiresAt: Date.now() + 60_000, + idToken: 'id-token-to-preserve', + email: 'test@example.com', + accountId: 'acct_123', + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', + }) + + service.setRefreshFn(async () => ({ + access_token: 'new-access-token', + expires_in: 3600, + })) + + const fresh = await service.ensureFreshAccessToken() + expect(fresh).toBe('new-access-token') + + const loaded = await service.loadTokens() + expect(loaded?.refreshToken).toBe('refresh-to-preserve') + expect(loaded?.idToken).toBe('id-token-to-preserve') + expect(loaded?.clientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann') + }) + test('returns null when refresh fails', async () => { await service.saveTokens({ accessToken: 'expired', diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 28d46b0a..696420f1 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -301,6 +301,143 @@ describe('ProviderService', () => { expect(fetched.name).toBe(added.name) }) + describe('ChatGPT Official provider metadata', () => { + test('normalizes the built-in ChatGPT provider as an active provider id', async () => { + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ activeId: 'openai-official', providers: [] }), + 'utf-8', + ) + + const svc = new ProviderService() + const result = await svc.listProviders() + + expect(result.activeId).toBe('openai-official') + expect(result.providers).toEqual([]) + }) + + test('returns built-in ChatGPT provider metadata without persisting secrets', async () => { + const svc = new ProviderService() + const provider = await svc.getProvider('openai-official') + + expect(provider).toMatchObject({ + id: 'openai-official', + presetId: 'openai-official', + name: 'ChatGPT Official', + apiKey: '', + apiFormat: 'openai_responses', + runtimeKind: 'openai_oauth', + models: { + main: 'gpt-5.3-codex', + haiku: 'gpt-5.4-mini', + sonnet: 'gpt-5.4', + opus: 'gpt-5.3-codex', + }, + }) + }) + + test('activating ChatGPT Official writes OpenAI OAuth runtime env without Anthropic auth or proxy env', async () => { + const svc = new ProviderService() + + await svc.activateProvider('openai-official') + + const config = await readProvidersConfig() + const settings = await readSettings() + expect(config.activeId).toBe('openai-official') + const env = settings.env as Record + expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1') + expect(env.OPENAI_CODEX_OAUTH_FILE).toBe( + path.join(tmpDir, 'cc-haha', 'openai-oauth.json'), + ) + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex') + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini') + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4') + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex') + expect(typeof env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBe('string') + expect(env.ANTHROPIC_BASE_URL).toBeUndefined() + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + }) + + test('activating ChatGPT Official clears stale managed provider env', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ + apiFormat: 'openai_responses', + baseUrl: 'https://api.example.com/openai', + models: { + main: 'provider-main', + haiku: 'provider-haiku', + sonnet: 'provider-sonnet', + opus: 'provider-opus', + }, + })) + await svc.activateProvider(provider.id) + expect(((await readSettings()).env as Record).ANTHROPIC_BASE_URL).toContain('/proxy') + + await svc.activateProvider('openai-official') + + const settings = await readSettings() + const env = settings.env as Record + expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1') + expect(env.OPENAI_CODEX_OAUTH_FILE).toBe( + path.join(tmpDir, 'cc-haha', 'openai-oauth.json'), + ) + expect(env.ANTHROPIC_BASE_URL).toBeUndefined() + expect(env.ANTHROPIC_API_KEY).toBeUndefined() + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + }) + + test('auth status reports ChatGPT Official from the desktop OpenAI token file', async () => { + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'openai-oauth.json'), + JSON.stringify({ + accessToken: 'openai-access', + refreshToken: 'openai-refresh', + expiresAt: Date.now() + 60 * 60_000, + email: 'user@example.com', + accountId: 'acct_123', + }), + 'utf-8', + ) + + const svc = new ProviderService() + await svc.activateProvider('openai-official') + + await expect(svc.checkAuthStatus()).resolves.toMatchObject({ + hasAuth: true, + source: 'openai-oauth', + activeProvider: 'ChatGPT Official', + }) + }) + + test('auth status reports ChatGPT Official as unauthenticated when the OpenAI token file is missing', async () => { + const svc = new ProviderService() + await svc.activateProvider('openai-official') + + await expect(svc.checkAuthStatus()).resolves.toMatchObject({ + hasAuth: false, + source: 'none', + activeProvider: 'ChatGPT Official', + }) + }) + + test('activating another provider clears ChatGPT Official runtime markers', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput()) + + await svc.activateProvider('openai-official') + await svc.activateProvider(provider.id) + + const env = (await readSettings()).env as Record + expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined() + expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined() + expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com') + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-test-key-123') + }) + }) + test('should throw 404 for non-existent id', async () => { const svc = new ProviderService() @@ -770,6 +907,14 @@ describe('ProviderService', () => { expect(active).toBeNull() }) + test('should return null for explicit ChatGPT Official proxy lookup', async () => { + const svc = new ProviderService() + + const active = await svc.getProviderForProxy('openai-official') + + expect(active).toBeNull() + }) + test('should return the active provider proxy config', async () => { const svc = new ProviderService() const provider = await svc.addProvider(sampleInput()) @@ -781,6 +926,15 @@ describe('ProviderService', () => { expect(active!.apiKey).toBe(provider.apiKey) expect(active!.apiFormat).toBe('anthropic') }) + + test('should return null when ChatGPT Official is the active provider', async () => { + const svc = new ProviderService() + await svc.activateProvider('openai-official') + + const active = await svc.getProviderForProxy() + + expect(active).toBeNull() + }) }) describe('handleProxyRequest', () => { diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index f82db41d..6240658e 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -626,6 +626,65 @@ describe('Models API', () => { expect(globalSettings.model).toBeUndefined() }) + it('GET /api/models should return the OpenAI model catalog when ChatGPT Official is active', async () => { + const providerSvc = new ProviderService() + await providerSvc.activateProvider('openai-official') + + const { req, url, segments } = makeRequest('GET', '/api/models') + const res = await handleModelsApi(req, url, segments) + + expect(res.status).toBe(200) + const body = await res.json() as { + models: Array<{ id: string; name: string }> + provider: { id: string; name: string } | null + } + expect(body.provider).toEqual({ + id: 'openai-official', + name: 'ChatGPT Official', + }) + expect(body.models.map((model) => model.id)).toEqual([ + 'gpt-5.3-codex', + 'gpt-5.4', + 'gpt-5.5', + 'gpt-5.4-mini', + ]) + }) + + it('PUT /api/models/current should persist GPT model to managed settings when ChatGPT Official is active', async () => { + const settingsSvc = new SettingsService() + const providerSvc = new ProviderService() + await settingsSvc.updateUserSettings({ model: 'claude-haiku-4-5' }) + await providerSvc.activateProvider('openai-official') + + const { req, url, segments } = makeRequest('PUT', '/api/models/current', { + modelId: 'gpt-5.5', + }) + const res = await handleModelsApi(req, url, segments) + + expect(res.status).toBe(200) + const managedSettings = await providerSvc.getManagedSettings() + expect(managedSettings.model).toBe('gpt-5.5') + + const globalSettings = await settingsSvc.getUserSettings() + expect(globalSettings.model).toBe('claude-haiku-4-5') + }) + + it('GET /api/models/current should read current GPT model from managed settings when ChatGPT Official is active', async () => { + const providerSvc = new ProviderService() + await providerSvc.activateProvider('openai-official') + await providerSvc.updateManagedSettings({ model: 'gpt-5.5' }) + + const { req, url, segments } = makeRequest('GET', '/api/models/current') + const res = await handleModelsApi(req, url, segments) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.model).toMatchObject({ + id: 'gpt-5.5', + name: 'GPT-5.5', + }) + }) + it('GET /api/effort should return default effort level', async () => { const { req, url, segments } = makeRequest('GET', '/api/effort') const res = await handleModelsApi(req, url, segments) diff --git a/src/server/api/haha-openai-oauth.ts b/src/server/api/haha-openai-oauth.ts index 501e1a31..21f01c2b 100644 --- a/src/server/api/haha-openai-oauth.ts +++ b/src/server/api/haha-openai-oauth.ts @@ -2,7 +2,8 @@ * Haha OpenAI OAuth REST API * * POST /api/haha-openai-oauth/start — 生成 PKCE+state,返回 authorize URL - * GET /callback/openai — 用户浏览器 redirect 到此,完成 token 交换 + * GET /auth/callback — 用户浏览器 redirect 到此,完成 token 交换 + * GET /callback/openai — 兼容旧路径 * GET /api/haha-openai-oauth — 查询当前登录状态(不回传 token 本体) * DELETE /api/haha-openai-oauth — 登出,删除 token 文件 */ diff --git a/src/server/api/models.ts b/src/server/api/models.ts index e39fd683..570b6338 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -14,6 +14,11 @@ import { attributionHeaderEnvForModel } from '../services/attributionHeaderPolic import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { hasOpenAIAuthLogin } from '../../utils/auth.js' import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js' +import { + OPENAI_OFFICIAL_PROVIDER_ID, + OPENAI_OFFICIAL_PROVIDER_NAME, + isOpenAIOfficialProviderId, +} from '../services/openaiOfficialProvider.js' // ─── Fallback models (used when no provider is configured) ──────────────────── @@ -110,6 +115,15 @@ function buildProviderModelList(models: { return modelList } +function buildOpenAIModelList(): ApiModelInfo[] { + return OPENAI_CODEX_MODEL_CATALOG.map(model => ({ + id: model.value, + name: model.label, + description: model.description, + context: '', + })) +} + function getEnvConfiguredAnthropicModels(): ApiModelInfo[] { return buildProviderModelList({ main: process.env.ANTHROPIC_MODEL?.trim() || '', @@ -124,12 +138,7 @@ function getOpenAIAuthModels(): ApiModelInfo[] { return [] } - return OPENAI_CODEX_MODEL_CATALOG.map(model => ({ - id: model.value, - name: model.label, - description: model.description, - context: '', - })) + return buildOpenAIModelList() } function getStandaloneModelList(): ApiModelInfo[] { @@ -190,6 +199,16 @@ export async function handleModelsApi( async function handleModelsList(): Promise { const { providers, activeId } = await providerService.listProviders() + if (isOpenAIOfficialProviderId(activeId)) { + return Response.json({ + models: buildOpenAIModelList(), + provider: { + id: OPENAI_OFFICIAL_PROVIDER_ID, + name: OPENAI_OFFICIAL_PROVIDER_NAME, + }, + }) + } + const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null if (activeProvider) { const modelList = buildProviderModelList(activeProvider.models) @@ -205,8 +224,9 @@ async function handleCurrentModel(req: Request): Promise { if (req.method === 'GET') { // Build the full model list: prefer active provider's models, fall back to defaults const { providers, activeId } = await providerService.listProviders() + const isOpenAIProviderActive = isOpenAIOfficialProviderId(activeId) const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null - const settings = activeProvider + const settings = activeProvider || isOpenAIProviderActive ? await providerService.getManagedSettings() : await settingsService.getUserSettings() const explicitModel = (settings.model as string) || '' @@ -217,7 +237,10 @@ async function handleCurrentModel(req: Request): Promise { let currentModelId: string let currentModelName: string - if (activeProvider) { + if (isOpenAIProviderActive) { + currentModelId = explicitModel || env.ANTHROPIC_MODEL || 'gpt-5.3-codex' + currentModelName = currentModelId + } else if (activeProvider) { // Provider is active — only use the provider-managed cc-haha settings. // This avoids leaking global ~/.claude/settings.json model choices into // the active provider flow. @@ -238,9 +261,11 @@ async function handleCurrentModel(req: Request): Promise { const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId // Build available models for name lookup - const availableModels = activeProvider - ? buildProviderModelList(activeProvider.models) - : getStandaloneModelList() + const availableModels = isOpenAIProviderActive + ? buildOpenAIModelList() + : activeProvider + ? buildProviderModelList(activeProvider.models) + : getStandaloneModelList() const modelEntry = availableModels.find((m) => m.id === lookupId) || availableModels.find((m) => m.id === currentModelId) diff --git a/src/server/index.ts b/src/server/index.ts index f9d7dadb..5b924d78 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,6 +15,7 @@ import { handleProxyRequest } from './proxy/handler.js' import { ProviderService } from './services/providerService.js' import { handleHahaOAuthCallback } from './api/haha-oauth.js' import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js' +import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js' import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js' import { enableConfigs } from '../utils/config.js' import { diagnosticsService } from './services/diagnosticsService.js' @@ -267,7 +268,10 @@ export function startServer(port = PORT, host = HOST) { return handleHahaOAuthCallback(url) } - if (url.pathname === '/callback/openai') { + if ( + url.pathname === OPENAI_CODEX_REDIRECT_PATH || + url.pathname === '/callback/openai' + ) { return handleHahaOpenAIOAuthCallback(url) } diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 53e73055..2f576311 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -10,6 +10,10 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { ProviderService } from './providerService.js' +import { + OPENAI_CODEX_OAUTH_FILE_ENV_KEY, + OPENAI_OAUTH_PROVIDER_ENV_KEY, +} from './openaiOfficialProvider.js' import { sessionService } from './sessionService.js' import { diagnosticsService } from './diagnosticsService.js' import { @@ -888,6 +892,8 @@ export class ConversationService { 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_ATTRIBUTION_HEADER', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', + OPENAI_OAUTH_PROVIDER_ENV_KEY, + OPENAI_CODEX_OAUTH_FILE_ENV_KEY, ] as const const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() @@ -1044,6 +1050,8 @@ export class ConversationService { 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_ATTRIBUTION_HEADER', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', + OPENAI_OAUTH_PROVIDER_ENV_KEY, + OPENAI_CODEX_OAUTH_FILE_ENV_KEY, ].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0) } catch { return false @@ -1074,6 +1082,9 @@ export class ConversationService { const raw = fs.readFileSync(settingsPath, 'utf-8') const parsed = JSON.parse(raw) as { env?: Record } const env = parsed.env ?? {} + if (env[OPENAI_OAUTH_PROVIDER_ENV_KEY] === '1') { + return false + } const hasProviderEnv = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', diff --git a/src/server/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index 6e718583..74738b9c 100644 --- a/src/server/services/hahaOpenAIOAuthService.ts +++ b/src/server/services/hahaOpenAIOAuthService.ts @@ -23,20 +23,20 @@ import { refreshOpenAITokens, isOpenAITokenExpired, normalizeOpenAITokens, + withRefreshedAccessToken, OPENAI_CODEX_OAUTH_PORT, OPENAI_CODEX_REDIRECT_PATH, } from '../../services/openaiAuth/client.js' -import type { - OpenAIOAuthTokens, - OpenAIOAuthTokenResponse, -} from '../../services/openaiAuth/types.js' +import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js' export type StoredOpenAIOAuthTokens = { accessToken: string refreshToken: string | null expiresAt: number | null + idToken?: string | null email: string | null accountId: string | null + clientId?: string | null } export type OpenAIOAuthSession = { @@ -53,6 +53,12 @@ type OpenAIRefreshFn = ( const SESSION_TTL_MS = 5 * 60 * 1000 +export function getHahaOpenAIOAuthFilePath(): string { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + return path.join(configDir, 'cc-haha', 'openai-oauth.json') +} + export class HahaOpenAIOAuthService { private sessions = new Map() private refreshFn: OpenAIRefreshFn = refreshOpenAITokens @@ -61,10 +67,8 @@ export class HahaOpenAIOAuthService { this.refreshFn = fn } - private getOAuthFilePath(): string { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') - return path.join(configDir, 'cc-haha', 'openai-oauth.json') + getOAuthFilePath(): string { + return getHahaOpenAIOAuthFilePath() } async loadTokens(): Promise { @@ -80,9 +84,17 @@ export class HahaOpenAIOAuthService { async saveTokens(tokens: StoredOpenAIOAuthTokens): Promise { const filePath = this.getOAuthFilePath() await fs.mkdir(path.dirname(filePath), { recursive: true }) - const tmp = `${filePath}.tmp.${process.pid}` - await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 }) - await fs.rename(tmp, filePath) + const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}` + let renamed = false + try { + await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 }) + await fs.rename(tmp, filePath) + renamed = true + } finally { + if (!renamed) { + await fs.rm(tmp, { force: true }).catch(() => {}) + } + } } async deleteTokens(): Promise { @@ -161,8 +173,10 @@ export class HahaOpenAIOAuthService { accessToken: normalized.accessToken, refreshToken: normalized.refreshToken, expiresAt: normalized.expiresAt, + idToken: normalized.idToken ?? null, email: normalized.email ?? null, accountId: normalized.accountId ?? null, + clientId: normalized.clientId ?? null, } await this.saveTokens(tokens) return tokens @@ -180,13 +194,26 @@ export class HahaOpenAIOAuthService { try { const refreshed = await this.refreshFn(tokens.refreshToken) - const normalized = normalizeOpenAITokens(refreshed) + const normalized = withRefreshedAccessToken( + { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + ...(tokens.idToken ? { idToken: tokens.idToken } : {}), + ...(tokens.email ? { email: tokens.email } : {}), + ...(tokens.accountId ? { accountId: tokens.accountId } : {}), + ...(tokens.clientId ? { clientId: tokens.clientId } : {}), + }, + refreshed, + ) const updated: StoredOpenAIOAuthTokens = { accessToken: normalized.accessToken, - refreshToken: normalized.refreshToken ?? tokens.refreshToken, + refreshToken: normalized.refreshToken, expiresAt: normalized.expiresAt, - email: normalized.email ?? tokens.email, - accountId: normalized.accountId ?? tokens.accountId, + idToken: normalized.idToken ?? null, + email: normalized.email ?? null, + accountId: normalized.accountId ?? null, + clientId: normalized.clientId ?? null, } await this.saveTokens(updated) return updated diff --git a/src/server/services/openaiOfficialProvider.ts b/src/server/services/openaiOfficialProvider.ts new file mode 100644 index 00000000..5d39cbc4 --- /dev/null +++ b/src/server/services/openaiOfficialProvider.ts @@ -0,0 +1,64 @@ +import { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js' +import { + OPENAI_DEFAULT_HAIKU_MODEL, + OPENAI_DEFAULT_MAIN_MODEL, + OPENAI_DEFAULT_SONNET_MODEL, + getOpenAIContextWindowForModel, +} from '../../services/openaiAuth/models.js' +import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js' +import { getHahaOpenAIOAuthFilePath } from './hahaOpenAIOAuthService.js' +import type { SavedProvider } from '../types/provider.js' + +export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' +export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official' +export const OPENAI_OAUTH_PROVIDER_ENV_KEY = 'CC_HAHA_OPENAI_OAUTH_PROVIDER' +export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE' + +export function isOpenAIOfficialProviderId( + id: string | null | undefined, +): boolean { + return id === OPENAI_OFFICIAL_PROVIDER_ID +} + +const openAIModels: SavedProvider['models'] = { + main: OPENAI_DEFAULT_MAIN_MODEL, + haiku: OPENAI_DEFAULT_HAIKU_MODEL, + sonnet: OPENAI_DEFAULT_SONNET_MODEL, + opus: OPENAI_DEFAULT_MAIN_MODEL, +} + +const modelContextWindows = Object.fromEntries( + Object.values(openAIModels) + .map((model) => [model, getOpenAIContextWindowForModel(model)] as const) + .filter((entry): entry is readonly [string, number] => entry[1] !== null), +) + +export const OPENAI_OFFICIAL_PROVIDER: SavedProvider = { + id: OPENAI_OFFICIAL_PROVIDER_ID, + presetId: OPENAI_OFFICIAL_PROVIDER_ID, + name: OPENAI_OFFICIAL_PROVIDER_NAME, + apiKey: '', + authStrategy: 'dual_dummy', + baseUrl: new URL('/backend-api/codex', OPENAI_CODEX_API_ENDPOINT) + .toString() + .replace(/\/+$/, ''), + apiFormat: 'openai_responses', + runtimeKind: 'openai_oauth', + models: openAIModels, + modelContextWindows, +} + +export function buildOpenAIOfficialRuntimeEnv(): Record { + const modelContextWindows = OPENAI_OFFICIAL_PROVIDER.modelContextWindows ?? {} + return { + [OPENAI_OAUTH_PROVIDER_ENV_KEY]: '1', + [OPENAI_CODEX_OAUTH_FILE_ENV_KEY]: getHahaOpenAIOAuthFilePath(), + ...(Object.keys(modelContextWindows).length > 0 && { + [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows), + }), + ANTHROPIC_MODEL: OPENAI_OFFICIAL_PROVIDER.models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: OPENAI_OFFICIAL_PROVIDER.models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: OPENAI_OFFICIAL_PROVIDER.models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: OPENAI_OFFICIAL_PROVIDER.models.opus, + } +} diff --git a/src/server/services/persistentStorageMigrations.ts b/src/server/services/persistentStorageMigrations.ts index f126659f..2a956a6a 100644 --- a/src/server/services/persistentStorageMigrations.ts +++ b/src/server/services/persistentStorageMigrations.ts @@ -3,6 +3,7 @@ import * as os from 'os' import * as path from 'path' import { randomBytes } from 'node:crypto' import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js' +import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js' export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1 @@ -136,7 +137,10 @@ function migrateProvidersIndex(value: unknown): JsonObject { : typeof _legacyActiveProviderId === 'string' ? _legacyActiveProviderId : null - const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId) + const activeId = rawActiveId && ( + providers.some((provider) => provider.id === rawActiveId) || + isOpenAIOfficialProviderId(rawActiveId) + ) ? rawActiveId : null diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 784781b1..7fffd31a 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -23,6 +23,14 @@ import { ATTRIBUTION_HEADER_ENV_KEY, attributionHeaderEnvForModel, } from './attributionHeaderPolicy.js' +import { + OPENAI_CODEX_OAUTH_FILE_ENV_KEY, + OPENAI_OFFICIAL_PROVIDER, + OPENAI_OAUTH_PROVIDER_ENV_KEY, + buildOpenAIOfficialRuntimeEnv, + isOpenAIOfficialProviderId, +} from './openaiOfficialProvider.js' +import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' import { CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, ensurePersistentStorageUpgraded, @@ -53,6 +61,8 @@ const MANAGED_ENV_KEYS = [ 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', ATTRIBUTION_HEADER_ENV_KEY, MODEL_CONTEXT_WINDOWS_ENV_KEY, + OPENAI_OAUTH_PROVIDER_ENV_KEY, + OPENAI_CODEX_OAUTH_FILE_ENV_KEY, ] as const const CUSTOM_PROVIDER_MODEL_CAPABILITIES = 'thinking,effort,adaptive_thinking,max_effort' @@ -80,12 +90,18 @@ function isProviderModels(value: unknown): value is SavedProvider['models'] { function isSavedProvider(value: unknown): value is SavedProvider { if (!isRecord(value)) return false + const runtimeKind = value.runtimeKind return ( typeof value.id === 'string' && typeof value.presetId === 'string' && typeof value.name === 'string' && typeof value.apiKey === 'string' && typeof value.baseUrl === 'string' && + ( + runtimeKind === undefined || + runtimeKind === 'anthropic_compatible' || + runtimeKind === 'openai_oauth' + ) && isProviderModels(value.models) ) } @@ -100,20 +116,34 @@ function normalizeModelMapping(models: SavedProvider['models']): SavedProvider[' } } +function normalizeSavedProvider(provider: SavedProvider): SavedProvider { + return { + ...provider, + apiFormat: provider.apiFormat ?? 'anthropic', + runtimeKind: provider.runtimeKind ?? 'anthropic_compatible', + models: normalizeModelMapping(provider.models), + } +} + function normalizeProvidersIndex(value: unknown): ProvidersIndex | null { if (!isRecord(value) || !Array.isArray(value.providers)) { return null } const { activeProviderId: _legacyActiveProviderId, ...rest } = value - const providers = value.providers.filter(isSavedProvider) + const providers = value.providers + .filter(isSavedProvider) + .map((provider) => normalizeSavedProvider(provider)) const rawActiveId = typeof value.activeId === 'string' ? value.activeId : typeof _legacyActiveProviderId === 'string' ? _legacyActiveProviderId : null - const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId) + const activeId = rawActiveId && ( + providers.some((provider) => provider.id === rawActiveId) || + isOpenAIOfficialProviderId(rawActiveId) + ) ? rawActiveId : null @@ -256,6 +286,10 @@ export class ProviderService { } async getProvider(id: string): Promise { + if (isOpenAIOfficialProviderId(id)) { + return OPENAI_OFFICIAL_PROVIDER + } + const index = await this.readIndex() const provider = index.providers.find((p) => p.id === id) if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) @@ -273,6 +307,7 @@ export class ProviderService { ...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }), baseUrl: input.baseUrl, apiFormat: input.apiFormat ?? 'anthropic', + runtimeKind: input.runtimeKind ?? 'anthropic_compatible', models: normalizeModelMapping(input.models), ...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }), ...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }), @@ -297,6 +332,7 @@ export class ProviderService { ...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }), ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), ...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }), + ...(input.runtimeKind !== undefined && { runtimeKind: input.runtimeKind }), ...(input.models !== undefined && { models: normalizeModelMapping(input.models) }), ...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }), ...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }), @@ -336,13 +372,17 @@ export class ProviderService { async activateProvider(id: string): Promise { const index = await this.readIndex() - const provider = index.providers.find((p) => p.id === id) + const provider = isOpenAIOfficialProviderId(id) + ? OPENAI_OFFICIAL_PROVIDER + : index.providers.find((p) => p.id === id) if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) index.activeId = id await this.writeIndex(index) - if (provider.presetId === 'official') { + if (provider.runtimeKind === 'openai_oauth') { + await this.syncToSettings(provider) + } else if (provider.presetId === 'official') { await this.clearProviderFromSettings() } else { await this.syncToSettings(provider) @@ -362,6 +402,10 @@ export class ProviderService { provider: SavedProvider, options?: { proxyPath?: string }, ): Record { + if (provider.runtimeKind === 'openai_oauth') { + return buildOpenAIOfficialRuntimeEnv() + } + const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic' const proxyPath = options?.proxyPath ?? '/proxy' const baseUrl = needsProxy @@ -467,12 +511,28 @@ export class ProviderService { */ async checkAuthStatus(): Promise<{ hasAuth: boolean - source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none' + source: 'cc-haha-provider' | 'openai-oauth' | 'original-settings' | 'env' | 'none' activeProvider?: string }> { // 1. Check cc-haha active provider const index = await this.readIndex() if (index.activeId) { + if (isOpenAIOfficialProviderId(index.activeId)) { + const tokens = await hahaOpenAIOAuthService.ensureFreshTokens() + if (tokens?.accessToken && tokens.refreshToken) { + return { + hasAuth: true, + source: 'openai-oauth', + activeProvider: OPENAI_OFFICIAL_PROVIDER.name, + } + } + return { + hasAuth: false, + source: 'none', + activeProvider: OPENAI_OFFICIAL_PROVIDER.name, + } + } + const provider = index.providers.find(p => p.id === index.activeId) if (provider) { const presetDefaultEnv = getPresetDefaultEnv(provider.presetId) @@ -513,6 +573,9 @@ export class ProviderService { apiFormat: ApiFormat } | null> { if (providerId) { + if (isOpenAIOfficialProviderId(providerId)) { + return null + } const provider = await this.getProvider(providerId) return { baseUrl: provider.baseUrl, @@ -523,7 +586,10 @@ export class ProviderService { const index = await this.readIndex() if (!index.activeId) return null - const provider = index.providers.find((p) => p.id === index.activeId) + if (isOpenAIOfficialProviderId(index.activeId)) { + return null + } + const provider = await this.getProvider(index.activeId).catch(() => null) if (!provider) return null return { baseUrl: provider.baseUrl, diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index a3022036..3ddf303f 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -23,6 +23,12 @@ export const ProviderAuthStrategySchema = z.enum([ ]) export type ProviderAuthStrategy = z.infer +export const ProviderRuntimeKindSchema = z.enum([ + 'anthropic_compatible', + 'openai_oauth', +]) +export type ProviderRuntimeKind = z.infer + export const ModelMappingSchema = z.object({ main: z.string(), haiku: z.string(), @@ -44,6 +50,7 @@ export const SavedProviderSchema = z.object({ authStrategy: ProviderAuthStrategySchema.optional(), baseUrl: z.string(), apiFormat: ApiFormatSchema.default('anthropic'), + runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'), models: ModelMappingSchema, autoCompactWindow: AutoCompactWindowSchema.optional(), modelContextWindows: ModelContextWindowsSchema.optional(), @@ -63,6 +70,7 @@ export const CreateProviderSchema = z.object({ authStrategy: ProviderAuthStrategySchema.optional(), baseUrl: z.string(), apiFormat: ApiFormatSchema.default('anthropic'), + runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'), models: ModelMappingSchema, autoCompactWindow: AutoCompactWindowSchema.optional(), modelContextWindows: ModelContextWindowsSchema.optional(), @@ -75,6 +83,7 @@ export const UpdateProviderSchema = z.object({ authStrategy: ProviderAuthStrategySchema.optional(), baseUrl: z.string().optional(), apiFormat: ApiFormatSchema.optional(), + runtimeKind: ProviderRuntimeKindSchema.optional(), models: ModelMappingSchema.optional(), autoCompactWindow: AutoCompactWindowSchema.nullable().optional(), modelContextWindows: ModelContextWindowsSchema.nullable().optional(), diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 4927c381..8f5a7dde 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -52,6 +52,36 @@ describe('resolveAnthropicClientApiKey', () => { }) }) +describe('shouldUseOpenAICodexTransport', () => { + test('lets ChatGPT Official marker override a saved Claude subscriber login', async () => { + const { shouldUseOpenAICodexTransport } = await import('./client.js') + + expect(shouldUseOpenAICodexTransport({ + hasOpenAIAuth: true, + isClaudeSubscriber: true, + forceOpenAICodex: true, + isOpenAIModel: true, + hasAnthropicAuthToken: false, + hasExplicitApiKey: false, + hasFallbackApiKey: false, + })).toBe(true) + }) + + test('keeps Claude subscriber transport when ChatGPT Official is not selected', async () => { + const { shouldUseOpenAICodexTransport } = await import('./client.js') + + expect(shouldUseOpenAICodexTransport({ + hasOpenAIAuth: true, + isClaudeSubscriber: true, + forceOpenAICodex: false, + isOpenAIModel: true, + hasAnthropicAuthToken: false, + hasExplicitApiKey: false, + hasFallbackApiKey: false, + })).toBe(false) + }) +}) + describe('getAnthropicClient', () => { test('passes bearer-token provider auth without an SDK api key', async () => { const { getAnthropicClient } = await import('./client.js') diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 0dc841c7..f3959d47 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -110,6 +110,31 @@ export function resolveAnthropicClientApiKey({ return explicitApiKey || getFallbackApiKey() } +export function shouldUseOpenAICodexTransport({ + hasOpenAIAuth, + isClaudeSubscriber, + forceOpenAICodex, + isOpenAIModel, + hasAnthropicAuthToken, + hasExplicitApiKey, + hasFallbackApiKey, +}: { + hasOpenAIAuth: boolean + isClaudeSubscriber: boolean + forceOpenAICodex: boolean + isOpenAIModel: boolean + hasAnthropicAuthToken: boolean + hasExplicitApiKey: boolean + hasFallbackApiKey: boolean +}): boolean { + return ( + hasOpenAIAuth && + (!isClaudeSubscriber || forceOpenAICodex) && + (isOpenAIModel || + (!hasAnthropicAuthToken && !hasExplicitApiKey && !hasFallbackApiKey)) + ) +} + export async function getAnthropicClient({ apiKey, maxRetries, @@ -158,14 +183,24 @@ export async function getAnthropicClient({ logForDebugging('[API:auth] OAuth token check complete') const isOpenAIModel = model ? isOpenAIResponsesModel(model) : false - const usingOpenAICodex = - shouldUseOpenAICodexAuth() && - !isClaudeAISubscriber() && - (isOpenAIModel || - (!process.env.ANTHROPIC_AUTH_TOKEN && - !(apiKey || getAnthropicApiKey()))) + const isClaudeSubscriber = isClaudeAISubscriber() + const forceOpenAICodex = isEnvTruthy(process.env.CC_HAHA_OPENAI_OAUTH_PROVIDER) + const hasOpenAIAuth = shouldUseOpenAICodexAuth() + const hasFallbackApiKey = hasOpenAIAuth && + !process.env.ANTHROPIC_AUTH_TOKEN && + !apiKey && + !!getAnthropicApiKey() + const usingOpenAICodex = shouldUseOpenAICodexTransport({ + hasOpenAIAuth, + isClaudeSubscriber, + forceOpenAICodex, + isOpenAIModel, + hasAnthropicAuthToken: !!process.env.ANTHROPIC_AUTH_TOKEN, + hasExplicitApiKey: !!apiKey, + hasFallbackApiKey, + }) - if (!isClaudeAISubscriber() && !usingOpenAICodex) { + if (!isClaudeSubscriber && !usingOpenAICodex) { await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession()) } @@ -334,12 +369,12 @@ export async function getAnthropicClient({ // Determine authentication method based on available tokens const clientConfig: ConstructorParameters[0] = { - apiKey: isClaudeAISubscriber() - ? null - : usingOpenAICodex - ? OPENAI_OAUTH_DUMMY_KEY + apiKey: usingOpenAICodex + ? OPENAI_OAUTH_DUMMY_KEY + : isClaudeSubscriber + ? null : resolveAnthropicClientApiKey({ explicitApiKey: apiKey }), - authToken: isClaudeAISubscriber() + authToken: isClaudeSubscriber && !usingOpenAICodex ? getClaudeAIOAuthTokens()?.accessToken : undefined, // Set baseURL from OAuth config when using staging OAuth diff --git a/src/services/openaiAuth/client.ts b/src/services/openaiAuth/client.ts index 23a0e7f0..e83a148c 100644 --- a/src/services/openaiAuth/client.ts +++ b/src/services/openaiAuth/client.ts @@ -69,6 +69,7 @@ export async function refreshOpenAITokens( grant_type: 'refresh_token', refresh_token: refreshToken, client_id: OPENAI_CODEX_CLIENT_ID, + scope: 'openid profile email', }).toString(), }) @@ -112,6 +113,10 @@ export function normalizeOpenAITokens( parseOpenAIJwtClaims(response.id_token) ?? parseOpenAIJwtClaims(response.access_token) + if (!response.refresh_token) { + throw new Error('OpenAI OAuth response did not include a refresh token') + } + return { accessToken: response.access_token, refreshToken: response.refresh_token, @@ -119,6 +124,7 @@ export function normalizeOpenAITokens( idToken: response.id_token, accountId: extractOpenAIAccountId(claims), email: claims?.email, + clientId: OPENAI_CODEX_CLIENT_ID, } } @@ -130,12 +136,17 @@ export function withRefreshedAccessToken( existing: OpenAIOAuthTokens, refreshed: OpenAIOAuthTokenResponse, ): OpenAIOAuthTokens { - const next = normalizeOpenAITokens(refreshed) + const claims = + parseOpenAIJwtClaims(refreshed.id_token) ?? + parseOpenAIJwtClaims(refreshed.access_token) return { - ...next, - accountId: next.accountId ?? existing.accountId, - email: next.email ?? existing.email, - idToken: next.idToken ?? existing.idToken, + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token ?? existing.refreshToken, + expiresAt: Date.now() + (refreshed.expires_in ?? 3600) * 1000, + idToken: refreshed.id_token ?? existing.idToken, + accountId: extractOpenAIAccountId(claims) ?? existing.accountId, + email: claims?.email ?? existing.email, + clientId: existing.clientId ?? OPENAI_CODEX_CLIENT_ID, } } diff --git a/src/services/openaiAuth/fetch.test.ts b/src/services/openaiAuth/fetch.test.ts new file mode 100644 index 00000000..d245cb8d --- /dev/null +++ b/src/services/openaiAuth/fetch.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { OPENAI_CODEX_API_ENDPOINT } from './client.js' +import { buildOpenAICodexFetch } from './fetch.js' +import { clearOpenAIOAuthTokenCache } from './storage.js' + +describe('buildOpenAICodexFetch', () => { + let tmpDir: string + let originalTokenFile: string | undefined + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openai-codex-fetch-')) + originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE + process.env.OPENAI_CODEX_OAUTH_FILE = path.join(tmpDir, 'openai-oauth.json') + clearOpenAIOAuthTokenCache() + await fs.writeFile( + process.env.OPENAI_CODEX_OAUTH_FILE, + JSON.stringify({ + accessToken: 'access-for-chatgpt', + refreshToken: 'refresh-for-chatgpt', + expiresAt: Date.now() + 60 * 60_000, + accountId: 'acct_fetch', + email: 'user@example.com', + }), + 'utf-8', + ) + }) + + afterEach(async () => { + if (originalTokenFile === undefined) { + delete process.env.OPENAI_CODEX_OAUTH_FILE + } else { + process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile + } + clearOpenAIOAuthTokenCache() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test('maps Anthropic messages to ChatGPT Codex responses endpoint with account header', async () => { + const upstreamCalls: Array<{ + url: string + headers: Record + body: Record + }> = [] + const fetchOverride: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers) + upstreamCalls.push({ + url: String(input), + headers: Object.fromEntries(headers.entries()), + body: JSON.parse(String(init?.body)) as Record, + }) + return Response.json({ + id: 'resp_123', + object: 'response', + created_at: 1_779_118_000, + model: 'gpt-5.5', + status: 'completed', + output: [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok' }], + }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }) + } + + const openAIFetch = buildOpenAICodexFetch(fetchOverride, 'test') + const response = await openAIFetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + model: 'gpt-5.5', + max_tokens: 64, + messages: [{ role: 'user', content: 'Say ok' }], + }), + }) + + expect(upstreamCalls).toHaveLength(1) + expect(upstreamCalls[0].url).toBe(OPENAI_CODEX_API_ENDPOINT) + expect(upstreamCalls[0].headers.authorization).toBe('Bearer access-for-chatgpt') + expect(upstreamCalls[0].headers['chatgpt-account-id']).toBe('acct_fetch') + expect(upstreamCalls[0].body.model).toBe('gpt-5.5') + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + type: 'message', + model: 'gpt-5.5', + content: [{ type: 'text', text: 'ok' }], + }) + }) +}) diff --git a/src/services/openaiAuth/storage.test.ts b/src/services/openaiAuth/storage.test.ts new file mode 100644 index 00000000..340daf4c --- /dev/null +++ b/src/services/openaiAuth/storage.test.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' +import * as fs from 'fs' +import * as fsp from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { + clearOpenAIOAuthTokenCache, + deleteOpenAIOAuthTokens, + getOpenAIOAuthTokens, + getOpenAIOAuthTokensAsync, + saveOpenAIOAuthTokens, +} from './storage.js' +import { plainTextStorage } from '../../utils/secureStorage/plainTextStorage.js' +import type { OpenAIOAuthTokens } from './types.js' + +describe('OpenAI OAuth desktop token file storage', () => { + let tmpDir: string + let tokenPath: string + let originalTokenFile: string | undefined + let originalConfigDir: string | undefined + let originalHome: string | undefined + let originalUserProfile: string | undefined + let originalCwd: string + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'openai-oauth-storage-')) + tokenPath = path.join(tmpDir, 'openai-oauth.json') + originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + originalCwd = process.cwd() + process.env.CLAUDE_CONFIG_DIR = tmpDir + process.env.HOME = tmpDir + process.env.USERPROFILE = tmpDir + process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath + clearOpenAIOAuthTokenCache() + }) + + afterEach(async () => { + plainTextStorage.delete() + if (originalTokenFile === undefined) { + delete process.env.OPENAI_CODEX_OAUTH_FILE + } else { + process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile + } + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + process.chdir(originalCwd) + clearOpenAIOAuthTokenCache() + await fsp.rm(tmpDir, { recursive: true, force: true }) + }) + + function seedSecureStorage(tokens: OpenAIOAuthTokens): void { + delete process.env.OPENAI_CODEX_OAUTH_FILE + clearOpenAIOAuthTokenCache() + expect( + plainTextStorage.update({ openaiCodexOauth: tokens }).success, + ).toBe(true) + process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath + clearOpenAIOAuthTokenCache() + } + + function unsetTokenFileOverride(): void { + delete process.env.OPENAI_CODEX_OAUTH_FILE + clearOpenAIOAuthTokenCache() + } + + test('reads desktop token file synchronously', async () => { + await fsp.writeFile( + tokenPath, + JSON.stringify({ + accessToken: 'desktop-access', + refreshToken: 'desktop-refresh', + expiresAt: 4_100_000_000_000, + idToken: 'desktop-id-token', + email: 'user@example.com', + accountId: 'acct_desktop', + }), + 'utf-8', + ) + + const tokens = getOpenAIOAuthTokens() + + expect(tokens).toMatchObject({ + accessToken: 'desktop-access', + refreshToken: 'desktop-refresh', + expiresAt: 4_100_000_000_000, + idToken: 'desktop-id-token', + email: 'user@example.com', + accountId: 'acct_desktop', + }) + }) + + test('reads desktop token file asynchronously', async () => { + await fsp.writeFile( + tokenPath, + JSON.stringify({ + accessToken: 'async-access', + refreshToken: 'async-refresh', + expiresAt: 4_100_000_000_000, + email: null, + accountId: null, + }), + 'utf-8', + ) + + const tokens = await getOpenAIOAuthTokensAsync() + + expect(tokens?.accessToken).toBe('async-access') + expect(tokens?.refreshToken).toBe('async-refresh') + }) + + test('writes refreshed tokens back to the desktop token file', async () => { + const result = saveOpenAIOAuthTokens({ + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh', + expiresAt: 4_100_000_000_000, + idToken: 'fresh-id-token', + email: 'fresh@example.com', + accountId: 'acct_fresh', + }) + + expect(result).toEqual({ success: true }) + const raw = JSON.parse( + fs.readFileSync(tokenPath, 'utf-8'), + ) as Record + expect(raw).toMatchObject({ + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh', + idToken: 'fresh-id-token', + email: 'fresh@example.com', + accountId: 'acct_fresh', + }) + if (process.platform !== 'win32') { + expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600) + } + }) + + test('file-backed save clears legacy secure storage tokens', async () => { + seedSecureStorage({ + accessToken: 'secure-access', + refreshToken: 'secure-refresh', + expiresAt: 4_100_000_000_000, + }) + + const result = saveOpenAIOAuthTokens({ + accessToken: 'file-access', + refreshToken: 'file-refresh', + expiresAt: 4_100_000_000_123, + }) + + expect(result).toEqual({ success: true }) + expect(getOpenAIOAuthTokens()?.accessToken).toBe('file-access') + + unsetTokenFileOverride() + expect(getOpenAIOAuthTokens()).toBeNull() + await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() + }) + + test('deletes the desktop token file when the env override is set', async () => { + await fsp.writeFile( + tokenPath, + JSON.stringify({ + accessToken: 'desktop-access', + refreshToken: 'desktop-refresh', + expiresAt: 4_100_000_000_000, + }), + 'utf-8', + ) + + expect(deleteOpenAIOAuthTokens()).toBe(true) + expect(fs.existsSync(tokenPath)).toBe(false) + }) + + test('returns null from both getters when env override is set but file is missing', async () => { + seedSecureStorage({ + accessToken: 'secure-access', + refreshToken: 'secure-refresh', + expiresAt: 4_100_000_000_000, + }) + + expect(getOpenAIOAuthTokens()).toBeNull() + await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() + }) + + test('returns null from both getters when env override file contains corrupt json', async () => { + seedSecureStorage({ + accessToken: 'secure-access', + refreshToken: 'secure-refresh', + expiresAt: 4_100_000_000_000, + }) + await fsp.writeFile(tokenPath, '{ definitely-not-json', 'utf-8') + + expect(getOpenAIOAuthTokens()).toBeNull() + await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() + }) + + test('does not fall back to secure storage after deleting env override file', async () => { + seedSecureStorage({ + accessToken: 'secure-access', + refreshToken: 'secure-refresh', + expiresAt: 4_100_000_000_000, + }) + await fsp.writeFile( + tokenPath, + JSON.stringify({ + accessToken: 'desktop-access', + refreshToken: 'desktop-refresh', + expiresAt: 4_100_000_000_000, + }), + 'utf-8', + ) + + expect(deleteOpenAIOAuthTokens()).toBe(true) + expect(getOpenAIOAuthTokens()).toBeNull() + await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() + + unsetTokenFileOverride() + expect(getOpenAIOAuthTokens()).toBeNull() + await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull() + }) + + test('reloads sync tokens when OPENAI_CODEX_OAUTH_FILE changes without clearing cache', async () => { + const tokenPathA = path.join(tmpDir, 'openai-oauth-a.json') + const tokenPathB = path.join(tmpDir, 'openai-oauth-b.json') + await fsp.writeFile( + tokenPathA, + JSON.stringify({ + accessToken: 'access-a', + refreshToken: 'refresh-a', + expiresAt: 4_100_000_000_001, + }), + 'utf-8', + ) + await fsp.writeFile( + tokenPathB, + JSON.stringify({ + accessToken: 'access-b', + refreshToken: 'refresh-b', + expiresAt: 4_100_000_000_002, + }), + 'utf-8', + ) + + process.env.OPENAI_CODEX_OAUTH_FILE = tokenPathA + expect(getOpenAIOAuthTokens()?.accessToken).toBe('access-a') + + process.env.OPENAI_CODEX_OAUTH_FILE = tokenPathB + expect(getOpenAIOAuthTokens()?.accessToken).toBe('access-b') + }) + + test('prefers env-pinned file authority when OPENAI_CODEX_OAUTH_FILE matches the secure-storage sentinel', async () => { + const sentinelPath = '__secure-storage__' + seedSecureStorage({ + accessToken: 'secure-access', + refreshToken: 'secure-refresh', + expiresAt: 4_100_000_000_000, + }) + + process.chdir(tmpDir) + process.env.OPENAI_CODEX_OAUTH_FILE = sentinelPath + await fsp.writeFile( + path.join(tmpDir, sentinelPath), + JSON.stringify({ + accessToken: 'file-access', + refreshToken: 'file-refresh', + expiresAt: 4_100_000_000_123, + }), + 'utf-8', + ) + clearOpenAIOAuthTokenCache() + + expect(getOpenAIOAuthTokens()).toMatchObject({ + accessToken: 'file-access', + refreshToken: 'file-refresh', + expiresAt: 4_100_000_000_123, + }) + await expect(getOpenAIOAuthTokensAsync()).resolves.toMatchObject({ + accessToken: 'file-access', + refreshToken: 'file-refresh', + expiresAt: 4_100_000_000_123, + }) + }) + + test('cleans up tmp file if desktop token rename fails', async () => { + const renameSyncSpy = spyOn(fs, 'renameSync').mockImplementation(() => { + const error = new Error('rename failed') as NodeJS.ErrnoException + error.code = 'EXDEV' + throw error + }) + + const result = saveOpenAIOAuthTokens({ + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh', + expiresAt: 4_100_000_000_000, + }) + + renameSyncSpy.mockRestore() + + expect(result.success).toBe(false) + const tmpFiles = (await fsp.readdir(tmpDir)).filter((name) => + name.startsWith('openai-oauth.json.tmp.'), + ) + expect(tmpFiles).toEqual([]) + }) +}) diff --git a/src/services/openaiAuth/storage.ts b/src/services/openaiAuth/storage.ts index 93f9b0a6..1d85d284 100644 --- a/src/services/openaiAuth/storage.ts +++ b/src/services/openaiAuth/storage.ts @@ -1,3 +1,6 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' import memoize from 'lodash-es/memoize.js' import { getSecureStorage } from '../../utils/secureStorage/index.js' import { errorMessage } from '../../utils/errors.js' @@ -5,20 +8,154 @@ import { logError } from '../../utils/log.js' import type { OpenAIOAuthTokens } from './types.js' const STORAGE_KEY = 'openaiCodexOauth' +export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE' type SecureStorageShape = Record & { openaiCodexOauth?: OpenAIOAuthTokens } +const SECURE_STORAGE_CACHE_KEY = 'secure-storage' +const FILE_CACHE_KEY_PREFIX = 'file:' +const FILE_BACKED_STORAGE_MARKER_FILE = 'openai-oauth-file-backed' + +function getDesktopTokenFilePath(): string | null { + const filePath = process.env[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]?.trim() + return filePath ? filePath : null +} + +function getCcHahaDir(): string { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + return path.join(configDir, 'cc-haha') +} + +function getFileBackedStorageMarkerPath(): string { + return path.join(getCcHahaDir(), FILE_BACKED_STORAGE_MARKER_FILE) +} + +function markFileBackedStorageUsed(): void { + try { + fs.mkdirSync(getCcHahaDir(), { recursive: true }) + fs.writeFileSync(getFileBackedStorageMarkerPath(), '1\n', { mode: 0o600 }) + } catch (error) { + logError(error) + } +} + +function clearFileBackedStorageMarker(): void { + try { + fs.rmSync(getFileBackedStorageMarkerPath(), { force: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logError(error) + } + } +} + +function isSecureStorageFallbackDisabled(): boolean { + return fs.existsSync(getFileBackedStorageMarkerPath()) +} + +function normalizeTokenFile(value: unknown): OpenAIOAuthTokens | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + + const record = value as Record + if ( + typeof record.accessToken !== 'string' || + typeof record.refreshToken !== 'string' || + typeof record.expiresAt !== 'number' + ) { + return null + } + + return { + accessToken: record.accessToken, + refreshToken: record.refreshToken, + expiresAt: record.expiresAt, + ...(typeof record.idToken === 'string' && { idToken: record.idToken }), + ...(typeof record.accountId === 'string' && { + accountId: record.accountId, + }), + ...(typeof record.email === 'string' && { email: record.email }), + ...(typeof record.clientId === 'string' && { clientId: record.clientId }), + } +} + +function readDesktopTokenFileSync(filePath = getDesktopTokenFilePath()): OpenAIOAuthTokens | null { + if (!filePath) return null + + try { + return normalizeTokenFile(JSON.parse(fs.readFileSync(filePath, 'utf-8'))) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logError(error) + } + return null + } +} + +async function readDesktopTokenFileAsync( + filePath = getDesktopTokenFilePath(), +): Promise { + if (!filePath) return null + + try { + const raw = await fs.promises.readFile(filePath, 'utf-8') + return normalizeTokenFile(JSON.parse(raw)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logError(error) + } + return null + } +} + +function writeDesktopTokenFileSync(tokens: OpenAIOAuthTokens): boolean { + const filePath = getDesktopTokenFilePath() + if (!filePath) return false + + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}` + let renamed = false + + try { + fs.writeFileSync(tmpFile, JSON.stringify(tokens, null, 2) + '\n', { + mode: 0o600, + }) + fs.renameSync(tmpFile, filePath) + renamed = true + return true + } finally { + if (!renamed) { + try { + fs.rmSync(tmpFile, { force: true }) + } catch (cleanupError) { + if ((cleanupError as NodeJS.ErrnoException).code !== 'ENOENT') { + logError(cleanupError) + } + } + } + } +} + export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { success: boolean warning?: string } { try { + if (writeDesktopTokenFileSync(tokens)) { + markFileBackedStorageUsed() + clearOpenAIOAuthTokenCache() + return { success: true } + } + const storage = getSecureStorage() const data = (storage.read() ?? {}) as SecureStorageShape data[STORAGE_KEY] = tokens const result = storage.update(data) + if (result.success) { + clearFileBackedStorageMarker() + } clearOpenAIOAuthTokenCache() return result } catch (error) { @@ -30,18 +167,46 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { } } -export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => { - try { - const storage = getSecureStorage() - const data = storage.read() as SecureStorageShape | null - return data?.openaiCodexOauth ?? null - } catch (error) { - logError(error) - return null - } -}) +const getOpenAIOAuthTokensCached = memoize( + (cacheKey: string): OpenAIOAuthTokens | null => { + if (cacheKey.startsWith(FILE_CACHE_KEY_PREFIX)) { + return readDesktopTokenFileSync( + cacheKey.slice(FILE_CACHE_KEY_PREFIX.length), + ) + } + + if (isSecureStorageFallbackDisabled()) { + return null + } + + try { + const storage = getSecureStorage() + const data = storage.read() as SecureStorageShape | null + return data?.openaiCodexOauth ?? null + } catch (error) { + logError(error) + return null + } + }, +) + +export function getOpenAIOAuthTokens(): OpenAIOAuthTokens | null { + const filePath = getDesktopTokenFilePath() + return getOpenAIOAuthTokensCached( + filePath ? `${FILE_CACHE_KEY_PREFIX}${filePath}` : SECURE_STORAGE_CACHE_KEY, + ) +} export async function getOpenAIOAuthTokensAsync(): Promise { + const filePath = getDesktopTokenFilePath() + if (filePath) { + return readDesktopTokenFileAsync(filePath) + } + + if (isSecureStorageFallbackDisabled()) { + return null + } + try { const storage = getSecureStorage() const data = (await storage.readAsync()) as SecureStorageShape | null @@ -53,11 +218,19 @@ export async function getOpenAIOAuthTokensAsync(): Promise