@@ -233,21 +236,22 @@ function ProviderSettings() {
{/* Official provider — always visible at top */}
+
{/* 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