mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
22c2e203ee
commit
9a11f35bcd
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user