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/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 10dcf615..542057c7 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -363,6 +363,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__/providers.test.ts b/src/server/__tests__/providers.test.ts index 134c4475..19cf815a 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -314,7 +314,7 @@ describe('ProviderService', () => { }) }) - test('activating ChatGPT Official only persists activeId for Task 1', async () => { + 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') @@ -322,7 +322,19 @@ describe('ProviderService', () => { const config = await readProvidersConfig() const settings = await readSettings() expect(config.activeId).toBe('openai-official') - expect(settings.env).toBeUndefined() + 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 () => { @@ -343,7 +355,63 @@ describe('ProviderService', () => { await svc.activateProvider('openai-official') const settings = await readSettings() - expect(settings.env).toBeUndefined() + 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') }) }) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 4444a4ef..b07143e8 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 { @@ -886,6 +890,8 @@ export class ConversationService { 'CC_HAHA_SEND_DISABLED_THINKING', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', + OPENAI_OAUTH_PROVIDER_ENV_KEY, + OPENAI_CODEX_OAUTH_FILE_ENV_KEY, ] as const const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() @@ -1035,6 +1041,8 @@ export class ConversationService { 'CC_HAHA_SEND_DISABLED_THINKING', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', '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 @@ -1065,6 +1073,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/openaiOfficialProvider.ts b/src/server/services/openaiOfficialProvider.ts index 8a8a0192..5d39cbc4 100644 --- a/src/server/services/openaiOfficialProvider.ts +++ b/src/server/services/openaiOfficialProvider.ts @@ -5,6 +5,8 @@ import { 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' @@ -45,3 +47,18 @@ export const OPENAI_OFFICIAL_PROVIDER: SavedProvider = { 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/providerService.ts b/src/server/services/providerService.ts index 4197baef..c69e4033 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -20,9 +20,13 @@ import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/typ import { PROVIDER_PRESETS } from '../config/providerPresets.js' import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.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, @@ -52,6 +56,8 @@ const MANAGED_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 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' @@ -358,12 +364,9 @@ export class ProviderService { index.activeId = id await this.writeIndex(index) - if (isOpenAIOfficialProviderId(id)) { - await this.clearProviderFromSettings() - return - } - - 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) @@ -383,6 +386,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 @@ -423,12 +430,6 @@ export class ProviderService { } async getProviderRuntimeEnv(id: string): Promise> { - if (isOpenAIOfficialProviderId(id)) { - // Task 1 only persists the built-in provider selection. Task 3 wires the - // dedicated OAuth runtime env for ChatGPT Official sessions. - return {} - } - const provider = await this.getProvider(id) return this.buildManagedEnv(provider, { proxyPath: `/proxy/providers/${provider.id}`, @@ -493,12 +494,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) diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index 16cf0045..d2c9eff4 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -41,6 +41,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_SKIP_VERTEX_AUTH', 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH', 'CLAUDE_CODE_SKIP_AZURE_OPENAI_AUTH', + 'CC_HAHA_OPENAI_OAUTH_PROVIDER', + 'OPENAI_CODEX_OAUTH_FILE', // Model defaults — often set to provider-specific ID formats 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',