Route ChatGPT Official through OpenAI OAuth env

Activating the built-in ChatGPT provider now writes a dedicated OpenAI OAuth runtime environment instead of clearing provider state. The runtime points the CLI at the desktop-managed OpenAI token file, avoids Anthropic auth/base-url variables, reports auth from the OpenAI token file, and prevents Claude OAuth injection when ChatGPT Official is active.

Constraint: Default provider sessions read cc-haha managed settings, while session-scoped provider selections inject env directly from the desktop host.

Rejected: Treat ChatGPT Official as a generic OpenAI proxy provider | proxy routing would require API-key shaped auth and would leak it into the wrong runtime path.

Confidence: high

Scope-risk: moderate

Tested: bun test src/server/__tests__/providers.test.ts

Tested: bun test src/server/__tests__/conversation-service.test.ts

Tested: bun test src/services/openaiAuth/storage.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts src/server/__tests__/haha-openai-oauth-api.test.ts

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 23:28:34 +08:00
parent 22c2e203ee
commit 9a11f35bcd
7 changed files with 175 additions and 17 deletions

View File

@ -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
}

View File

@ -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<string, string>
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<string, string>
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 })

View File

@ -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<string, string>
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<string, string>
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<string, string>
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')
})
})

View File

@ -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<string, string> }
const env = parsed.env ?? {}
if (env[OPENAI_OAUTH_PROVIDER_ENV_KEY] === '1') {
return false
}
const hasProviderEnv = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',

View File

@ -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<string, string> {
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,
}
}

View File

@ -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<string, string> {
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<Record<string, string>> {
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)

View File

@ -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',