From b3cc32e43d380076bb367bfdeb32f2dcb4ea501f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 22:35:30 +0800 Subject: [PATCH 01/19] Add a typed ChatGPT provider runtime ChatGPT OAuth needs a stable built-in provider identity and a runtime kind that survives provider index normalization. This keeps normal saved providers unchanged while letting the desktop treat openai-official as an active provider without storing secrets in providers.json. Constraint: providers.json migration must preserve activeId=openai-official even when providers[] is empty Constraint: ChatGPT Official metadata must stay token-free in the provider index Rejected: Treat ChatGPT Official as a normal openai_responses provider row | it would require persisting a fake saved provider and would still lose activeId during migration Confidence: high Scope-risk: narrow Directive: Do not teach later OpenAI OAuth work to depend on providers[] containing the built-in provider Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "ChatGPT Official provider metadata" Tested: bun test src/server/__tests__/providers.test.ts Tested: cd desktop && bun run lint Tested: git diff --check --- desktop/src/types/provider.ts | 5 ++ src/server/__tests__/providers.test.ts | 37 +++++++++++++++ src/server/services/openaiOfficialProvider.ts | 47 +++++++++++++++++++ .../services/persistentStorageMigrations.ts | 6 ++- src/server/services/providerService.ts | 39 +++++++++++++-- src/server/types/provider.ts | 9 ++++ 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 src/server/services/openaiOfficialProvider.ts 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__/providers.test.ts b/src/server/__tests__/providers.test.ts index b08e561c..23bc26c6 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -278,6 +278,43 @@ 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('should throw 404 for non-existent id', async () => { const svc = new ProviderService() diff --git a/src/server/services/openaiOfficialProvider.ts b/src/server/services/openaiOfficialProvider.ts new file mode 100644 index 00000000..8a8a0192 --- /dev/null +++ b/src/server/services/openaiOfficialProvider.ts @@ -0,0 +1,47 @@ +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 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, +} 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 2d4202f0..a9783bfd 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -19,6 +19,10 @@ import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesTo import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js' import { PROVIDER_PRESETS } from '../config/providerPresets.js' import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js' +import { + OPENAI_OFFICIAL_PROVIDER, + isOpenAIOfficialProviderId, +} from './openaiOfficialProvider.js' import { CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, ensurePersistentStorageUpgraded, @@ -75,30 +79,49 @@ 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) ) } +function normalizeSavedProvider(provider: SavedProvider): SavedProvider { + return { + ...provider, + apiFormat: provider.apiFormat ?? 'anthropic', + runtimeKind: provider.runtimeKind ?? 'anthropic_compatible', + } +} + 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 @@ -241,6 +264,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}`) @@ -258,6 +285,7 @@ export class ProviderService { ...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }), baseUrl: input.baseUrl, apiFormat: input.apiFormat ?? 'anthropic', + runtimeKind: input.runtimeKind ?? 'anthropic_compatible', models: input.models, ...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }), ...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }), @@ -282,6 +310,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: input.models }), ...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }), ...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }), @@ -321,7 +350,9 @@ 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 @@ -507,7 +538,7 @@ export class ProviderService { const index = await this.readIndex() if (!index.activeId) return null - const provider = index.providers.find((p) => p.id === index.activeId) + 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(), From 072b913fd22b37f7cf07d5167f9a9cdd0caa6ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 22:46:36 +0800 Subject: [PATCH 02/19] Keep ChatGPT Official activation metadata-only until OAuth runtime lands Task 1 should only persist the built-in provider selection. The generic managed env and proxy paths remain reserved for saved Anthropic-compatible providers until the dedicated OpenAI OAuth runtime is wired in a later task. Constraint: Task 1 must not write managed provider env for openai-official Rejected: Reuse buildManagedEnv for openai-official | writes placeholder Anthropic env before Task 3 runtime exists Rejected: Return generic proxy config for openai-official | lets empty-key proxy resolution masquerade as a working runtime Confidence: high Scope-risk: narrow Directive: Do not route openai-official through managed settings or generic proxy lookup until the dedicated OAuth runtime is implemented Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "ChatGPT Official" Tested: bun test src/server/__tests__/providers.test.ts Tested: bun run check:server --- src/server/__tests__/providers.test.ts | 29 ++++++++++++++++++++++++++ src/server/services/providerService.ts | 16 ++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 23bc26c6..8d01edf7 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -313,6 +313,18 @@ describe('ProviderService', () => { }, }) }) + + test('activating ChatGPT Official only persists activeId for Task 1', async () => { + const svc = new ProviderService() + + await svc.activateProvider('openai-official') + + const config = await readProvidersConfig() + expect(config.activeId).toBe('openai-official') + await expect( + fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8'), + ).rejects.toThrow() + }) }) test('should throw 404 for non-existent id', async () => { @@ -740,6 +752,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()) @@ -751,6 +771,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('testProvider', () => { diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index a9783bfd..cc0fba8e 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -358,6 +358,10 @@ export class ProviderService { index.activeId = id await this.writeIndex(index) + if (isOpenAIOfficialProviderId(id)) { + return + } + if (provider.presetId === 'official') { await this.clearProviderFromSettings() } else { @@ -418,6 +422,12 @@ 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}`, @@ -528,6 +538,9 @@ export class ProviderService { apiFormat: ApiFormat } | null> { if (providerId) { + if (isOpenAIOfficialProviderId(providerId)) { + return null + } const provider = await this.getProvider(providerId) return { baseUrl: provider.baseUrl, @@ -538,6 +551,9 @@ export class ProviderService { const index = await this.readIndex() if (!index.activeId) return null + if (isOpenAIOfficialProviderId(index.activeId)) { + return null + } const provider = await this.getProvider(index.activeId).catch(() => null) if (!provider) return null return { From 238ab65cf3556241e95396172a40b80fa6b385da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 22:49:26 +0800 Subject: [PATCH 03/19] Clear stale provider env for ChatGPT Official activation Switching from a saved provider to the built-in ChatGPT provider must remove previously managed Anthropic env without writing any OpenAI runtime env yet. This keeps Task 1 metadata-only while preventing stale provider settings from surviving the switch. Constraint: Task 1 still must not write ChatGPT OAuth runtime env Rejected: Return without touching settings.json | stale ANTHROPIC_* env survives from the previous provider Confidence: high Scope-risk: narrow Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "ChatGPT Official" Tested: bun test src/server/__tests__/providers.test.ts Tested: git diff --check --- src/server/__tests__/providers.test.ts | 26 +++++++++++++++++++++++--- src/server/services/providerService.ts | 1 + 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 8d01edf7..134c4475 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -320,10 +320,30 @@ describe('ProviderService', () => { await svc.activateProvider('openai-official') const config = await readProvidersConfig() + const settings = await readSettings() expect(config.activeId).toBe('openai-official') - await expect( - fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8'), - ).rejects.toThrow() + expect(settings.env).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() + expect(settings.env).toBeUndefined() }) }) diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index cc0fba8e..4197baef 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -359,6 +359,7 @@ export class ProviderService { await this.writeIndex(index) if (isOpenAIOfficialProviderId(id)) { + await this.clearProviderFromSettings() return } From 0927c80cdc256ff8db5ed0a8d7e39e160a9debca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 22:56:26 +0800 Subject: [PATCH 04/19] Unify desktop OpenAI OAuth token storage Desktop OpenAI authorization lived in the cc-haha token file while the runtime only read secure storage, so desktop-managed logins could not refresh or authenticate CLI/runtime paths consistently. This teaches runtime storage to honor the explicit desktop token-file env, preserves refresh metadata when OpenAI omits fields, and keeps the desktop service writing the same compatible token shape. Constraint: Desktop sessions must survive macOS Keychain ACL failures by using the desktop-managed token file when it is explicitly configured Rejected: Keep refresh writes in secure storage only | runtime and desktop would remain split and refreshed tokens would drift Confidence: high Scope-risk: moderate Tested: bun test src/services/openaiAuth/storage.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts Tested: git diff --check --- .../haha-openai-oauth-service.test.ts | 30 ++++- src/server/services/hahaOpenAIOAuthService.ts | 43 ++++-- src/services/openaiAuth/client.ts | 21 ++- src/services/openaiAuth/storage.test.ts | 122 ++++++++++++++++++ src/services/openaiAuth/storage.ts | 93 +++++++++++++ src/services/openaiAuth/types.ts | 5 +- 6 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 src/services/openaiAuth/storage.test.ts diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index 505d9ad9..5e129072 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -8,6 +8,7 @@ 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) @@ -167,6 +170,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/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index 6e718583..b9dde1af 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 { @@ -161,8 +165,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 +186,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/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/storage.test.ts b/src/services/openaiAuth/storage.test.ts new file mode 100644 index 00000000..fd080869 --- /dev/null +++ b/src/services/openaiAuth/storage.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, 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' + +describe('OpenAI OAuth desktop token file storage', () => { + let tmpDir: string + let tokenPath: string + let originalTokenFile: string | undefined + + 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 + process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath + clearOpenAIOAuthTokenCache() + }) + + afterEach(async () => { + if (originalTokenFile === undefined) { + delete process.env.OPENAI_CODEX_OAUTH_FILE + } else { + process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile + } + clearOpenAIOAuthTokenCache() + await fsp.rm(tmpDir, { recursive: true, force: true }) + }) + + 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('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) + }) +}) diff --git a/src/services/openaiAuth/storage.ts b/src/services/openaiAuth/storage.ts index 93f9b0a6..666085ab 100644 --- a/src/services/openaiAuth/storage.ts +++ b/src/services/openaiAuth/storage.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs' +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,16 +7,94 @@ 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 } +function getDesktopTokenFilePath(): string | null { + const filePath = process.env[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]?.trim() + return filePath ? filePath : null +} + +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(): OpenAIOAuthTokens | null { + const filePath = getDesktopTokenFilePath() + 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(): Promise { + const filePath = getDesktopTokenFilePath() + 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()}` + fs.writeFileSync(tmpFile, JSON.stringify(tokens, null, 2) + '\n', { + mode: 0o600, + }) + fs.renameSync(tmpFile, filePath) + return true +} + export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { success: boolean warning?: string } { try { + if (writeDesktopTokenFileSync(tokens)) { + clearOpenAIOAuthTokenCache() + return { success: true } + } + const storage = getSecureStorage() const data = (storage.read() ?? {}) as SecureStorageShape data[STORAGE_KEY] = tokens @@ -31,6 +111,9 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { } export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => { + const desktopTokens = readDesktopTokenFileSync() + if (desktopTokens) return desktopTokens + try { const storage = getSecureStorage() const data = storage.read() as SecureStorageShape | null @@ -42,6 +125,9 @@ export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => { }) export async function getOpenAIOAuthTokensAsync(): Promise { + const desktopTokens = await readDesktopTokenFileAsync() + if (desktopTokens) return desktopTokens + try { const storage = getSecureStorage() const data = (await storage.readAsync()) as SecureStorageShape | null @@ -58,6 +144,13 @@ export function clearOpenAIOAuthTokenCache(): void { export function deleteOpenAIOAuthTokens(): boolean { try { + const filePath = getDesktopTokenFilePath() + if (filePath) { + fs.rmSync(filePath, { force: true }) + clearOpenAIOAuthTokenCache() + return true + } + const storage = getSecureStorage() const data = (storage.read() ?? {}) as SecureStorageShape delete data[STORAGE_KEY] diff --git a/src/services/openaiAuth/types.ts b/src/services/openaiAuth/types.ts index 6a2d9e5e..8c558f12 100644 --- a/src/services/openaiAuth/types.ts +++ b/src/services/openaiAuth/types.ts @@ -1,8 +1,10 @@ export type OpenAIOAuthTokenResponse = { access_token: string - refresh_token: string + refresh_token?: string id_token?: string expires_in?: number + scope?: string + token_type?: string } export type OpenAIOAuthTokens = { @@ -12,6 +14,7 @@ export type OpenAIOAuthTokens = { idToken?: string accountId?: string email?: string + clientId?: string } export type OpenAIJwtClaims = { From b61df181d349dbf98062e82814ccb0d8f8348ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:08:48 +0800 Subject: [PATCH 05/19] Prevent env-pinned OpenAI OAuth reads from resurrecting stale secure tokens When OPENAI_CODEX_OAUTH_FILE is set, the override file must stay the only source of truth. Reads now short-circuit to that file path, sync caching keys off the resolved override path, and failed atomic writes remove their tmp files before returning control. Constraint: OPENAI_CODEX_OAUTH_FILE must override shared secure storage even when the file is missing or corrupt Rejected: Keep zero-arg memoization and rely on manual cache clears | callers can switch override paths without touching the cache Confidence: high Scope-risk: narrow Directive: Do not reintroduce secure-storage fallback while the override env var is set Tested: bun test src/services/openaiAuth/storage.test.ts src/server/__tests__/haha-openai-oauth-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: bun run check:server Tested: git diff --check --- src/services/openaiAuth/storage.test.ts | 134 +++++++++++++++++++++++- src/services/openaiAuth/storage.ts | 76 +++++++++----- 2 files changed, 185 insertions(+), 25 deletions(-) diff --git a/src/services/openaiAuth/storage.test.ts b/src/services/openaiAuth/storage.test.ts index fd080869..a03ee2e3 100644 --- a/src/services/openaiAuth/storage.test.ts +++ b/src/services/openaiAuth/storage.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +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' @@ -10,30 +10,67 @@ import { 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 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 + 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 + } 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() + } + test('reads desktop token file synchronously', async () => { await fsp.writeFile( tokenPath, @@ -119,4 +156,99 @@ describe('OpenAI OAuth desktop token file storage', () => { 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() + }) + + 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('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 666085ab..0c4eff51 100644 --- a/src/services/openaiAuth/storage.ts +++ b/src/services/openaiAuth/storage.ts @@ -13,6 +13,8 @@ type SecureStorageShape = Record & { openaiCodexOauth?: OpenAIOAuthTokens } +const SECURE_STORAGE_CACHE_KEY = '__secure-storage__' + function getDesktopTokenFilePath(): string | null { const filePath = process.env[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]?.trim() return filePath ? filePath : null @@ -43,8 +45,7 @@ function normalizeTokenFile(value: unknown): OpenAIOAuthTokens | null { } } -function readDesktopTokenFileSync(): OpenAIOAuthTokens | null { - const filePath = getDesktopTokenFilePath() +function readDesktopTokenFileSync(filePath = getDesktopTokenFilePath()): OpenAIOAuthTokens | null { if (!filePath) return null try { @@ -57,8 +58,9 @@ function readDesktopTokenFileSync(): OpenAIOAuthTokens | null { } } -async function readDesktopTokenFileAsync(): Promise { - const filePath = getDesktopTokenFilePath() +async function readDesktopTokenFileAsync( + filePath = getDesktopTokenFilePath(), +): Promise { if (!filePath) return null try { @@ -78,11 +80,26 @@ function writeDesktopTokenFileSync(tokens: OpenAIOAuthTokens): boolean { fs.mkdirSync(path.dirname(filePath), { recursive: true }) const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}` - fs.writeFileSync(tmpFile, JSON.stringify(tokens, null, 2) + '\n', { - mode: 0o600, - }) - fs.renameSync(tmpFile, filePath) - return true + 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): { @@ -110,23 +127,34 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { } } -export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => { - const desktopTokens = readDesktopTokenFileSync() - if (desktopTokens) return desktopTokens +const getOpenAIOAuthTokensCached = memoize( + (cacheKey: string): OpenAIOAuthTokens | null => { + if (cacheKey !== SECURE_STORAGE_CACHE_KEY) { + return readDesktopTokenFileSync(cacheKey) + } - try { - const storage = getSecureStorage() - const data = storage.read() as SecureStorageShape | null - return data?.openaiCodexOauth ?? null - } catch (error) { - logError(error) - 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 { + return getOpenAIOAuthTokensCached( + getDesktopTokenFilePath() ?? SECURE_STORAGE_CACHE_KEY, + ) +} export async function getOpenAIOAuthTokensAsync(): Promise { - const desktopTokens = await readDesktopTokenFileAsync() - if (desktopTokens) return desktopTokens + const filePath = getDesktopTokenFilePath() + if (filePath) { + return readDesktopTokenFileAsync(filePath) + } try { const storage = getSecureStorage() @@ -139,7 +167,7 @@ export async function getOpenAIOAuthTokensAsync(): Promise Date: Mon, 18 May 2026 23:13:15 +0800 Subject: [PATCH 06/19] Prevent secure-storage cache keys from overriding env-pinned OAuth files The sync token reader memoized raw path strings and reserved '__secure-storage__' as the secure-storage discriminator. That allowed a real OPENAI_CODEX_OAUTH_FILE value with the same relative path to bypass the file branch and return secure-storage data instead. Use structured cache keys so file-backed lookups always memoize under a prefixed path while secure storage keeps a dedicated discriminator. Add a regression that seeds different secure-storage and file tokens for '__secure-storage__' and proves both sync and async getters honor the env-pinned file. Constraint: OPENAI_CODEX_OAUTH_FILE must remain authoritative even for relative paths that match internal sentinel names Rejected: Keep the raw sentinel and special-case path equality | still leaves memoization coupled to a valid user-controlled filename Directive: Preserve the file-vs-secure-storage branch split whenever sync cache keys change so env-pinned file authority stays collision-proof Confidence: high Scope-risk: narrow Tested: bun test src/services/openaiAuth/storage.test.ts --test-name-pattern "prefers env-pinned file authority when OPENAI_CODEX_OAUTH_FILE matches the secure-storage sentinel" Tested: bun test src/services/openaiAuth/storage.test.ts src/server/__tests__/haha-openai-oauth-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 --- src/services/openaiAuth/storage.test.ts | 36 +++++++++++++++++++++++++ src/services/openaiAuth/storage.ts | 12 ++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/services/openaiAuth/storage.test.ts b/src/services/openaiAuth/storage.test.ts index a03ee2e3..1c732d57 100644 --- a/src/services/openaiAuth/storage.test.ts +++ b/src/services/openaiAuth/storage.test.ts @@ -20,6 +20,7 @@ describe('OpenAI OAuth desktop token file storage', () => { 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-')) @@ -28,6 +29,7 @@ describe('OpenAI OAuth desktop token file storage', () => { 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 @@ -57,6 +59,7 @@ describe('OpenAI OAuth desktop token file storage', () => { } else { process.env.USERPROFILE = originalUserProfile } + process.chdir(originalCwd) clearOpenAIOAuthTokenCache() await fsp.rm(tmpDir, { recursive: true, force: true }) }) @@ -230,6 +233,39 @@ describe('OpenAI OAuth desktop token file storage', () => { 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 diff --git a/src/services/openaiAuth/storage.ts b/src/services/openaiAuth/storage.ts index 0c4eff51..db2d9a3f 100644 --- a/src/services/openaiAuth/storage.ts +++ b/src/services/openaiAuth/storage.ts @@ -13,7 +13,8 @@ type SecureStorageShape = Record & { openaiCodexOauth?: OpenAIOAuthTokens } -const SECURE_STORAGE_CACHE_KEY = '__secure-storage__' +const SECURE_STORAGE_CACHE_KEY = 'secure-storage' +const FILE_CACHE_KEY_PREFIX = 'file:' function getDesktopTokenFilePath(): string | null { const filePath = process.env[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]?.trim() @@ -129,8 +130,10 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { const getOpenAIOAuthTokensCached = memoize( (cacheKey: string): OpenAIOAuthTokens | null => { - if (cacheKey !== SECURE_STORAGE_CACHE_KEY) { - return readDesktopTokenFileSync(cacheKey) + if (cacheKey.startsWith(FILE_CACHE_KEY_PREFIX)) { + return readDesktopTokenFileSync( + cacheKey.slice(FILE_CACHE_KEY_PREFIX.length), + ) } try { @@ -145,8 +148,9 @@ const getOpenAIOAuthTokensCached = memoize( ) export function getOpenAIOAuthTokens(): OpenAIOAuthTokens | null { + const filePath = getDesktopTokenFilePath() return getOpenAIOAuthTokensCached( - getDesktopTokenFilePath() ?? SECURE_STORAGE_CACHE_KEY, + filePath ? `${FILE_CACHE_KEY_PREFIX}${filePath}` : SECURE_STORAGE_CACHE_KEY, ) } From 98fa5a0eddb0213bbba7d3cd4c032bf61cd78ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:15:09 +0800 Subject: [PATCH 07/19] Harden OpenAI OAuth token file writes The desktop OpenAI OAuth token store writes plaintext refresh credentials through a temporary file before rename. A failed rename previously left that temporary file behind in the server-side store, so the writer now mirrors the CLI storage cleanup path and removes failed temp files before surfacing the original write failure. Constraint: OpenAI OAuth token files may contain refresh and id tokens. Rejected: Rely on logout cleanup | failed writes can leave orphan temp files before logout runs. Confidence: high Scope-risk: narrow 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 --- .../haha-openai-oauth-service.test.ts | 32 ++++++++++++++++++- src/server/services/hahaOpenAIOAuthService.ts | 14 ++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index 5e129072..8bd331b7 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -2,7 +2,7 @@ * 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' @@ -75,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', () => { diff --git a/src/server/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index b9dde1af..74738b9c 100644 --- a/src/server/services/hahaOpenAIOAuthService.ts +++ b/src/server/services/hahaOpenAIOAuthService.ts @@ -84,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 { From 22c2e203eeeaee46e8a99aa1e12c093f3248549b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:21:06 +0800 Subject: [PATCH 08/19] Prevent legacy OpenAI OAuth token resurrection File-backed desktop OpenAI OAuth must not later fall through to stale secure-storage credentials when a process launches without OPENAI_CODEX_OAUTH_FILE. The file-backed save and delete paths now leave a local marker that disables legacy secure-storage reads for that config directory, while an explicit secure-storage save clears the marker. Constraint: Avoid synchronous macOS keychain work on the file-backed token save path. Rejected: Delete secure-storage credentials during file-backed save | it adds keychain latency and can block the desktop OAuth write path. Confidence: high Scope-risk: narrow 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 --- src/services/openaiAuth/storage.test.ts | 30 ++++++++++++++++ src/services/openaiAuth/storage.ts | 48 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/services/openaiAuth/storage.test.ts b/src/services/openaiAuth/storage.test.ts index 1c732d57..340daf4c 100644 --- a/src/services/openaiAuth/storage.test.ts +++ b/src/services/openaiAuth/storage.test.ts @@ -74,6 +74,11 @@ describe('OpenAI OAuth desktop token file storage', () => { clearOpenAIOAuthTokenCache() } + function unsetTokenFileOverride(): void { + delete process.env.OPENAI_CODEX_OAUTH_FILE + clearOpenAIOAuthTokenCache() + } + test('reads desktop token file synchronously', async () => { await fsp.writeFile( tokenPath, @@ -145,6 +150,27 @@ describe('OpenAI OAuth desktop token file storage', () => { } }) + 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, @@ -202,6 +228,10 @@ describe('OpenAI OAuth desktop token file storage', () => { 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 () => { diff --git a/src/services/openaiAuth/storage.ts b/src/services/openaiAuth/storage.ts index db2d9a3f..1d85d284 100644 --- a/src/services/openaiAuth/storage.ts +++ b/src/services/openaiAuth/storage.ts @@ -1,4 +1,5 @@ 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' @@ -15,12 +16,46 @@ type SecureStorageShape = Record & { 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 @@ -109,6 +144,7 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { } { try { if (writeDesktopTokenFileSync(tokens)) { + markFileBackedStorageUsed() clearOpenAIOAuthTokenCache() return { success: true } } @@ -117,6 +153,9 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): { const data = (storage.read() ?? {}) as SecureStorageShape data[STORAGE_KEY] = tokens const result = storage.update(data) + if (result.success) { + clearFileBackedStorageMarker() + } clearOpenAIOAuthTokenCache() return result } catch (error) { @@ -136,6 +175,10 @@ const getOpenAIOAuthTokensCached = memoize( ) } + if (isSecureStorageFallbackDisabled()) { + return null + } + try { const storage = getSecureStorage() const data = storage.read() as SecureStorageShape | null @@ -160,6 +203,10 @@ export async function getOpenAIOAuthTokensAsync(): Promise Date: Mon, 18 May 2026 23:28:34 +0800 Subject: [PATCH 09/19] 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 --- desktop/src/api/providers.ts | 2 +- .../__tests__/conversation-service.test.ts | 43 +++++++++++ src/server/__tests__/providers.test.ts | 74 ++++++++++++++++++- src/server/services/conversationService.ts | 11 +++ src/server/services/openaiOfficialProvider.ts | 17 +++++ src/server/services/providerService.ts | 43 +++++++---- src/utils/managedEnvConstants.ts | 2 + 7 files changed, 175 insertions(+), 17 deletions(-) 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', From 67a64c7f8010d13b687fdbbb1dcf2f775224543e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:32:22 +0800 Subject: [PATCH 10/19] Scrub ChatGPT OAuth env from provider settings JSON The desktop provider settings editor has its own JSON scrubber for provider-managed env. It now removes the ChatGPT Official OAuth marker and token-file path along with the Anthropic provider env keys, so editing another provider cannot keep stale OpenAI OAuth runtime markers in previewed settings JSON. Constraint: Desktop JSON preview cleanup is separate from server managed-env filtering. Rejected: Rely only on server activation cleanup | the settings editor can preview and persist user-edited JSON before the server rebuilds provider env. Confidence: high Scope-risk: narrow Tested: cd desktop && bun test src/lib/__tests__/providerSettingsJson.test.ts Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "ChatGPT Official|getProviderForProxy" Tested: bun test src/server/__tests__/conversation-service.test.ts --test-name-pattern "ChatGPT Official|session-scoped provider" Tested: git diff --check --- desktop/src/lib/__tests__/providerSettingsJson.test.ts | 2 ++ desktop/src/lib/providerSettingsJson.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/desktop/src/lib/__tests__/providerSettingsJson.test.ts b/desktop/src/lib/__tests__/providerSettingsJson.test.ts index 94c47807..6bd9795d 100644 --- a/desktop/src/lib/__tests__/providerSettingsJson.test.ts +++ b/desktop/src/lib/__tests__/providerSettingsJson.test.ts @@ -52,6 +52,8 @@ describe('provider settings JSON helpers', () => { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_MODEL: 'old-model', CLAUDE_CODE_MODEL_CONTEXT_WINDOWS: '{"old":100000}', + CC_HAHA_OPENAI_OAUTH_PROVIDER: '1', + OPENAI_CODEX_OAUTH_FILE: '/tmp/openai-oauth.json', CC_HAHA_SEND_DISABLED_THINKING: '1', USER_DEFINED: 'keep-me', }, diff --git a/desktop/src/lib/providerSettingsJson.ts b/desktop/src/lib/providerSettingsJson.ts index 9ad5fc09..e76e79ba 100644 --- a/desktop/src/lib/providerSettingsJson.ts +++ b/desktop/src/lib/providerSettingsJson.ts @@ -20,9 +20,11 @@ const PROVIDER_SETTINGS_JSON_ENV_KEYS = new Set([ 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME', 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_SMALL_FAST_MODEL', + 'CC_HAHA_OPENAI_OAUTH_PROVIDER', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', 'CLAUDE_CODE_SUBAGENT_MODEL', + 'OPENAI_CODEX_OAUTH_FILE', ]) function getEnvRecord(raw: string): Record | null { From 1ff563d4b0b4d9daec4ff856aff14afc4652785b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:39:31 +0800 Subject: [PATCH 11/19] Expose ChatGPT Official model catalog When ChatGPT Official is active, the model API now returns the OpenAI Codex catalog and reads or writes the selected GPT model from cc-haha managed settings. Desktop provider activation also recognizes the built-in provider id and resets the current model to the OpenAI default instead of looking for a saved provider record. Constraint: ChatGPT Official is a built-in provider and is not present in providers.json providers[]. Rejected: Reuse the four-slot provider model list | it hides GPT-5.5 and misrepresents the OpenAI catalog as Anthropic slot names. Confidence: high Scope-risk: moderate Tested: bun test src/server/__tests__/settings.test.ts Tested: bun test src/server/__tests__/providers.test.ts --test-name-pattern "ChatGPT Official|getProviderForProxy" Tested: bun test src/server/__tests__/conversation-service.test.ts --test-name-pattern "ChatGPT Official|session-scoped provider" Tested: cd desktop && bun run test src/stores/providerStore.test.ts Tested: git diff --check --- .../src/constants/openaiOfficialProvider.ts | 2 + desktop/src/stores/providerStore.test.ts | 37 +++++++++++- desktop/src/stores/providerStore.ts | 17 ++++-- src/server/__tests__/settings.test.ts | 59 +++++++++++++++++++ src/server/api/models.ts | 47 +++++++++++---- 5 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 desktop/src/constants/openaiOfficialProvider.ts diff --git a/desktop/src/constants/openaiOfficialProvider.ts b/desktop/src/constants/openaiOfficialProvider.ts new file mode 100644 index 00000000..fce7d9e8 --- /dev/null +++ b/desktop/src/constants/openaiOfficialProvider.ts @@ -0,0 +1,2 @@ +export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' +export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex' 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/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index a38a651b..87f89b59 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -625,6 +625,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/models.ts b/src/server/api/models.ts index eadb2867..c602958c 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -13,6 +13,11 @@ import { ProviderService } from '../services/providerService.js' 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) ──────────────────── @@ -109,6 +114,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() || '', @@ -123,12 +137,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[] { @@ -189,6 +198,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) @@ -204,8 +223,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) || '' @@ -216,7 +236,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. @@ -237,9 +260,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) From 6e1ba2753e09a665ac0a156f55dd772381fc3fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:45:53 +0800 Subject: [PATCH 12/19] Represent ChatGPT Official as a desktop built-in provider ChatGPT Official uses a real provider id, so desktop UI consumers cannot keep treating only null as the official lane. The model selector now exposes the GPT catalog for that built-in provider, and Settings renders a separate active card instead of folding it into Claude Official state. Constraint: Claude Official remains represented by activeId null. Rejected: Reuse the Claude Official card for ChatGPT Official | it hides the active ChatGPT provider and shows the wrong login surface. Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- src/components/controls/ModelSelector.test.tsx Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx --testNamePattern "Providers tab" Tested: git diff --check --- .../src/__tests__/generalSettings.test.tsx | 14 +++++ .../controls/ModelSelector.test.tsx | 51 +++++++++++++++++++ .../src/components/controls/ModelSelector.tsx | 41 ++++++++++++--- .../src/constants/openaiOfficialProvider.ts | 30 +++++++++++ desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/pages/Settings.tsx | 40 ++++++++++++--- 7 files changed, 168 insertions(+), 12 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 2cba509e..f59e7447 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -668,6 +668,7 @@ describe('Settings > Providers tab', () => { MOCK_DELETE_PROVIDER.mockReset() MOCK_GET_SETTINGS.mockResolvedValue({}) MOCK_UPDATE_SETTINGS.mockResolvedValue({}) + useSettingsStore.setState({ locale: 'en' }) providerStoreState.providers = [ { id: 'provider-1', @@ -709,6 +710,19 @@ describe('Settings > Providers tab', () => { expect(screen.getByTestId('claude-official-login')).toBeInTheDocument() }) + it('shows ChatGPT Official as the active built-in provider', () => { + providerStoreState.providers = [] + providerStoreState.activeId = 'openai-official' + providerStoreState.hasLoadedProviders = true + + render() + + const openAIProvider = screen.getByTestId('openai-official-provider') + expect(within(openAIProvider).getByText('ChatGPT Official')).toBeInTheDocument() + expect(within(openAIProvider).getByText('Default')).toBeInTheDocument() + expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() + }) + it('requires confirmation before deleting a provider', async () => { render() diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 7c98d126..59f9e0ea 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -7,6 +7,7 @@ import { useChatStore } from '../../stores/chatStore' import { useProviderStore } from '../../stores/providerStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useSettingsStore } from '../../stores/settingsStore' +import { OPENAI_OFFICIAL_PROVIDER_ID } from '../../constants/openaiOfficialProvider' import type { ModelInfo } from '../../types/settings' const MODELS: ModelInfo[] = [ @@ -118,6 +119,56 @@ describe('ModelSelector', () => { }) }) + it('uses the ChatGPT Official catalog when that built-in provider is active', async () => { + const openAIModels: ModelInfo[] = [ + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + description: 'Best for coding and agentic work', + context: '', + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'Latest general-purpose model', + context: '', + }, + ] + const setSessionRuntime = vi.fn() + useSettingsStore.setState({ + locale: 'en', + availableModels: openAIModels, + currentModel: openAIModels[0], + activeProviderName: 'ChatGPT Official', + }) + useProviderStore.setState({ + providers: [], + activeId: OPENAI_OFFICIAL_PROVIDER_ID, + hasLoadedProviders: true, + isLoading: true, + }) + useChatStore.setState({ + setSessionRuntime, + } as Partial>) + + render() + + await clickByRole(/GPT-5\.3 Codex/i) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /GPT-5\.5/ })) + await Promise.resolve() + }) + + expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({ + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + }) + expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', { + providerId: OPENAI_OFFICIAL_PROVIDER_ID, + modelId: 'gpt-5.5', + }) + }) + it('portals the dropdown outside clipping containers and positions it below the trigger', async () => { useSettingsStore.setState({ locale: 'en', diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index a1a88014..b39ed9a7 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog' +import { + OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + OPENAI_OFFICIAL_MODELS, + OPENAI_OFFICIAL_PROVIDER_ID, +} from '../../constants/openaiOfficialProvider' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' import { useProviderStore } from '../../stores/providerStore' @@ -43,12 +48,17 @@ const VIEWPORT_MARGIN = 16 const DROPDOWN_MAX_HEIGHT = 420 const DROPDOWN_MIN_HEIGHT = 180 -function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice { +function officialChoices( + providerId: string | null, + models: ModelInfo[], + isDefault: boolean, + officialName: string, +): ProviderChoice { return { - providerId: null, + providerId, providerName: officialName, isDefault, - models: availableModels.length > 0 ? availableModels : OFFICIAL_MODELS, + models, } } @@ -89,10 +99,24 @@ function buildProviderChoices( activeId: string | null, availableModels: ModelInfo[], officialName: string, + openAIOfficialName: string, labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>, ): ProviderChoice[] { + const claudeOfficialModels = activeId === null && availableModels.length > 0 + ? availableModels + : OFFICIAL_MODELS + const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0 + ? availableModels + : OPENAI_OFFICIAL_MODELS + return [ - officialChoices(availableModels, activeId === null, officialName), + officialChoices(null, claudeOfficialModels, activeId === null, officialName), + officialChoices( + OPENAI_OFFICIAL_PROVIDER_ID, + openAIOfficialModels, + activeId === OPENAI_OFFICIAL_PROVIDER_ID, + openAIOfficialName, + ), ...providers.map((provider) => ({ providerId: provider.id, providerName: provider.name, @@ -116,7 +140,11 @@ function resolveDefaultRuntimeSelection( return { providerId: inferredProviderId, - modelId: currentModelId ?? OFFICIAL_DEFAULT_MODEL_ID, + modelId: currentModelId ?? ( + inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID + ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID + : OFFICIAL_DEFAULT_MODEL_ID + ), } } @@ -258,8 +286,9 @@ export function ModelSelector({ () => buildProviderChoices( providers, activeId, - activeId === null ? availableModels : OFFICIAL_MODELS, + availableModels, t('settings.providers.officialName'), + t('settings.providers.openaiOfficialName'), roleLabels, ), [activeId, availableModels, providers, roleLabels, t], diff --git a/desktop/src/constants/openaiOfficialProvider.ts b/desktop/src/constants/openaiOfficialProvider.ts index fce7d9e8..d38947e1 100644 --- a/desktop/src/constants/openaiOfficialProvider.ts +++ b/desktop/src/constants/openaiOfficialProvider.ts @@ -1,2 +1,32 @@ +import type { ModelInfo } from '../types/settings' + export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex' +export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official' + +export const OPENAI_OFFICIAL_MODELS: ModelInfo[] = [ + { + id: OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + name: 'GPT-5.3 Codex', + description: 'Best for coding and agentic work', + context: '', + }, + { + id: 'gpt-5.4', + name: 'GPT-5.4', + description: 'Strong general-purpose model', + context: '', + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'Latest general-purpose model', + context: '', + }, + { + id: 'gpt-5.4-mini', + name: 'GPT-5.4 Mini', + description: 'Fastest for quick tasks', + context: '', + }, +] diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 76ff2c90..ad1f0d1f 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -277,6 +277,8 @@ export const en = { 'settings.providers.addProvider': 'Add Provider', 'settings.providers.officialName': 'Claude Official', 'settings.providers.officialDesc': 'Anthropic native — no API key required', + 'settings.providers.openaiOfficialName': 'ChatGPT Official', + 'settings.providers.openaiOfficialDesc': 'OpenAI OAuth via your ChatGPT account — no API key required', 'settings.providers.connected': 'Connected ({latency}ms)', 'settings.providers.failed': 'Failed: {error}', 'settings.providers.connectivityOk': '① Connectivity ({latency}ms)', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index f2ad0c1b..3bed1c29 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -279,6 +279,8 @@ export const zh: Record = { 'settings.providers.addProvider': '添加服务商', 'settings.providers.officialName': 'Claude 官方', 'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥', + 'settings.providers.openaiOfficialName': 'ChatGPT 官方', + 'settings.providers.openaiOfficialDesc': '通过 ChatGPT 账号完成 OpenAI OAuth — 无需 API 密钥', 'settings.providers.connected': '已连接 ({latency}ms)', 'settings.providers.failed': '失败: {error}', 'settings.providers.connectivityOk': '① 连通 ({latency}ms)', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 1bdbbef6..68675b38 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -32,6 +32,7 @@ import { ActivitySettings } from './ActivitySettings' import { MemorySettings } from './MemorySettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' +import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' import { isTauriRuntime } from '../lib/desktopRuntime' @@ -216,7 +217,8 @@ function ProviderSettings() { await fetchSettings() } - const isOfficialActive = hasLoadedProviders && activeId === null + const isClaudeOfficialActive = hasLoadedProviders && activeId === null + const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID return (
@@ -233,21 +235,22 @@ function ProviderSettings() { {/* Official provider — always visible at top */}
!isOfficialActive && handleActivateOfficial()} + onClick={() => !isClaudeOfficialActive && handleActivateOfficial()} > - +
{t('settings.providers.officialName')} - {isOfficialActive && ( + {isClaudeOfficialActive && ( {t('settings.providers.default')} )}
@@ -255,13 +258,38 @@ function ProviderSettings() {
- {isOfficialActive && ( + {isClaudeOfficialActive && (
)}
+
+
!isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)} + > + +
+
+ {t('settings.providers.openaiOfficialName')} + {isOpenAIOfficialActive && ( + {t('settings.providers.default')} + )} +
+
{t('settings.providers.openaiOfficialDesc')}
+
+
+
+ {/* Saved providers */} {isLoading && providers.length === 0 ? (
From 7713c956218de6a15837073703aabc761ad7f47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:50:31 +0800 Subject: [PATCH 13/19] Prefer ChatGPT OAuth when its provider is selected Desktop provider choice must override a separate Claude OAuth login. The OpenAI Codex fetch path is explicitly forced by the ChatGPT Official runtime marker, and the Anthropic SDK client no longer carries Claude authToken when that OpenAI transport is selected. Constraint: Users can have both Claude OAuth and ChatGPT OAuth saved. Rejected: Rely on isClaudeAISubscriber to choose transport | it makes Claude login override the selected ChatGPT provider. Confidence: high Scope-risk: moderate Tested: bun test src/services/api/client.test.ts Tested: bun test src/services/openaiAuth/fetch.test.ts src/server/__tests__/conversation-service.test.ts --test-name-pattern "ChatGPT Official|buildOpenAICodexFetch" Tested: git diff --check --- src/services/api/client.test.ts | 30 +++++++++ src/services/api/client.ts | 59 +++++++++++++---- src/services/openaiAuth/fetch.test.ts | 91 +++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 src/services/openaiAuth/fetch.test.ts 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 62fc6c9b..753d9489 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -109,6 +109,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, @@ -157,14 +182,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()) } @@ -333,12 +368,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/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' }], + }) + }) +}) From 83ee4c09ec79752a1c48cff1ef0354d279d0a035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:53:11 +0800 Subject: [PATCH 14/19] Add desktop ChatGPT OAuth controls The desktop app now has a dedicated OpenAI OAuth API client and store mirroring the existing Claude Official flow. The component uses the existing browser-open plus polling pattern and never exposes token material to the UI. Constraint: OAuth status responses must not return token bodies Rejected: Reuse Claude OAuth store | the status shape and endpoint differ Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- src/stores/hahaOpenAIOAuthStore.test.ts Tested: git diff --check --- desktop/src/api/hahaOpenAIOAuth.ts | 38 +++++++ .../settings/ChatGPTOfficialLogin.tsx | 104 ++++++++++++++++++ desktop/src/i18n/locales/en.ts | 11 ++ desktop/src/i18n/locales/zh.ts | 11 ++ .../src/stores/hahaOpenAIOAuthStore.test.ts | 97 ++++++++++++++++ desktop/src/stores/hahaOpenAIOAuthStore.ts | 101 +++++++++++++++++ 6 files changed, 362 insertions(+) create mode 100644 desktop/src/api/hahaOpenAIOAuth.ts create mode 100644 desktop/src/components/settings/ChatGPTOfficialLogin.tsx create mode 100644 desktop/src/stores/hahaOpenAIOAuthStore.test.ts create mode 100644 desktop/src/stores/hahaOpenAIOAuthStore.ts diff --git a/desktop/src/api/hahaOpenAIOAuth.ts b/desktop/src/api/hahaOpenAIOAuth.ts new file mode 100644 index 00000000..2aefbea6 --- /dev/null +++ b/desktop/src/api/hahaOpenAIOAuth.ts @@ -0,0 +1,38 @@ +// desktop/src/api/hahaOpenAIOAuth.ts + +import { api, getBaseUrl } from './client' + +export type HahaOpenAIOAuthStatus = + | { loggedIn: false } + | { + loggedIn: true + expiresAt: number | null + email: string | null + accountId: string | null + } + +function currentServerPort(): number { + const port = new URL(getBaseUrl()).port + const parsed = Number.parseInt(port, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`) + } + return parsed +} + +export const hahaOpenAIOAuthApi = { + start() { + return api.post<{ authorizeUrl: string; state: string }>( + '/api/haha-openai-oauth/start', + { serverPort: currentServerPort() }, + ) + }, + + status() { + return api.get('/api/haha-openai-oauth') + }, + + logout() { + return api.delete<{ ok: true }>('/api/haha-openai-oauth') + }, +} diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx new file mode 100644 index 00000000..4bbe0082 --- /dev/null +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx @@ -0,0 +1,104 @@ +// desktop/src/components/settings/ChatGPTOfficialLogin.tsx + +import { useEffect } from 'react' +import { open as shellOpen } from '@tauri-apps/plugin-shell' +import { LogIn, LogOut } from 'lucide-react' +import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' +import { useTranslation } from '../../i18n' + +export function ChatGPTOfficialLogin() { + const t = useTranslation() + const { + status, + isLoading, + error, + fetchStatus, + login, + logout, + startPolling, + stopPolling, + } = useHahaOpenAIOAuthStore() + + useEffect(() => { + void fetchStatus() + return () => stopPolling() + }, [fetchStatus, stopPolling]) + + const handleLogin = async () => { + try { + const { authorizeUrl } = await login() + try { + await shellOpen(authorizeUrl) + startPolling() + } catch (err) { + console.error('[ChatGPTOfficialLogin] shellOpen failed:', err) + useHahaOpenAIOAuthStore.setState({ + error: t('settings.chatgptOfficialLogin.openBrowserFailed'), + }) + } + } catch { + // store.login() errors are already captured into store.error + } + } + + if (status === null) { + if (error) { + return ( +
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ ) + } + return ( +
+ {t('common.loading')} +
+ ) + } + + if (status.loggedIn) { + const accountLabel = status.email || status.accountId || t('settings.chatgptOfficialLogin.accountUnknown') + return ( +
+ + {t('settings.chatgptOfficialLogin.loggedInPrefix')} {accountLabel} + + +
+ ) + } + + return ( +
+
+ {t('settings.chatgptOfficialLogin.intro')} +
+ + {error && ( +
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ )} +
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ad1f0d1f..0d57aa4a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -271,6 +271,17 @@ export const en = { 'settings.claudeOfficialLogin.errorPrefix': 'Error: ', 'settings.claudeOfficialLogin.openBrowserFailed': 'Failed to open browser; please visit the authorization URL manually.', + // Settings > ChatGPT Official Login + 'settings.chatgptOfficialLogin.intro': 'Sign in with ChatGPT to use GPT models from desktop sessions.', + 'settings.chatgptOfficialLogin.loginButton': 'Sign in with ChatGPT', + 'settings.chatgptOfficialLogin.loginStarting': 'Starting sign-in...', + 'settings.chatgptOfficialLogin.logoutButton': 'Sign out', + 'settings.chatgptOfficialLogin.logoutProcessing': 'Signing out...', + 'settings.chatgptOfficialLogin.loggedInPrefix': 'Signed in as', + 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT account', + 'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization URL from server logs and open it manually.', + 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth error: ', + // Settings > Providers 'settings.providers.title': 'Providers', 'settings.providers.description': 'Manage API providers for model access.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 3bed1c29..392d090b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -273,6 +273,17 @@ export const zh: Record = { 'settings.claudeOfficialLogin.errorPrefix': '错误:', 'settings.claudeOfficialLogin.openBrowserFailed': '无法打开浏览器,请手动访问授权链接。', + // Settings > ChatGPT Official Login + 'settings.chatgptOfficialLogin.intro': '登录 ChatGPT 后,就可以在桌面端会话里使用 GPT 模型。', + 'settings.chatgptOfficialLogin.loginButton': '登录 ChatGPT', + 'settings.chatgptOfficialLogin.loginStarting': '正在启动登录...', + 'settings.chatgptOfficialLogin.logoutButton': '退出登录', + 'settings.chatgptOfficialLogin.logoutProcessing': '正在退出...', + 'settings.chatgptOfficialLogin.loggedInPrefix': '已登录', + 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT 账号', + 'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请从服务端日志复制授权链接并手动打开。', + 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:', + // Settings > Providers 'settings.providers.title': '服务商', 'settings.providers.description': '管理 API 服务商以访问模型。', 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 }) + }, + } +}) From 7a818ac728d9153452de8ce9318d86b8a0780074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 18 May 2026 23:54:03 +0800 Subject: [PATCH 15/19] Surface ChatGPT Official OAuth status in settings Settings now mounts the ChatGPT OAuth status controls only after providers have loaded and ChatGPT Official is confirmed active. This keeps token-refreshing status reads scoped to the selected built-in provider. Constraint: OAuth status reads can refresh tokens as a side effect Rejected: Mount ChatGPT OAuth status unconditionally | it repeats the old official OAuth status misfire pattern Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx --testNamePattern "ChatGPT|official OAuth|Providers tab" Tested: git diff --check --- desktop/src/__tests__/generalSettings.test.tsx | 15 +++++++++++++++ desktop/src/pages/Settings.tsx | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index f59e7447..44096ec1 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -69,6 +69,10 @@ vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ ClaudeOfficialLogin: () =>
, })) +vi.mock('../components/settings/ChatGPTOfficialLogin', () => ({ + ChatGPTOfficialLogin: () =>
, +})) + vi.mock('../pages/AdapterSettings', () => ({ AdapterSettings: () =>
Adapter Settings Mock
, })) @@ -700,6 +704,16 @@ describe('Settings > Providers tab', () => { expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() }) + it('does not query ChatGPT OAuth status before providers finish loading', () => { + providerStoreState.providers = [] + providerStoreState.activeId = 'openai-official' + providerStoreState.hasLoadedProviders = false + + render() + + expect(screen.queryByTestId('chatgpt-official-login')).not.toBeInTheDocument() + }) + it('shows official OAuth status only after official provider is confirmed active', () => { providerStoreState.providers = [] providerStoreState.activeId = null @@ -720,6 +734,7 @@ describe('Settings > Providers tab', () => { const openAIProvider = screen.getByTestId('openai-official-provider') expect(within(openAIProvider).getByText('ChatGPT Official')).toBeInTheDocument() expect(within(openAIProvider).getByText('Default')).toBeInTheDocument() + expect(screen.getByTestId('chatgpt-official-login')).toBeInTheDocument() expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() }) diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 68675b38..27293317 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -32,6 +32,7 @@ import { ActivitySettings } from './ActivitySettings' import { MemorySettings } from './MemorySettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' +import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin' import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' @@ -288,6 +289,12 @@ function ProviderSettings() {
{t('settings.providers.openaiOfficialDesc')}
+ + {isOpenAIOfficialActive && ( +
+ +
+ )}
{/* Saved providers */} From d2ff9d0c000e538e8a4a3c06e65374f3650d892e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 00:04:11 +0800 Subject: [PATCH 16/19] Preserve ChatGPT OAuth fallback authorization links When the desktop shell cannot open a browser, the ChatGPT OAuth component keeps the transient authorization URL available for copy and starts polling after that explicit fallback action. Constraint: OAuth authorization URLs are transient UI state and must not be persisted. Rejected: Tell users to find the URL in server logs | the desktop flow does not expose those logs as the recovery surface. Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- src/components/settings/ChatGPTOfficialLogin.test.tsx src/stores/hahaOpenAIOAuthStore.test.ts Tested: cd desktop && bun run test -- src/components/settings/ChatGPTOfficialLogin.test.tsx src/stores/hahaOpenAIOAuthStore.test.ts src/__tests__/generalSettings.test.tsx --testNamePattern "ChatGPT|OpenAI OAuth|Providers tab|ChatGPTOfficialLogin|hahaOpenAIOAuthStore" Tested: cd desktop && bun run lint Tested: git diff --check --- .../settings/ChatGPTOfficialLogin.test.tsx | 96 +++++++++++++++++++ .../settings/ChatGPTOfficialLogin.tsx | 40 +++++++- desktop/src/i18n/locales/en.ts | 4 +- desktop/src/i18n/locales/zh.ts | 4 +- 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx new file mode 100644 index 00000000..48f7b93b --- /dev/null +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +const { + copyTextToClipboardMock, + logoutMock, + shellOpenMock, + startMock, + statusMock, +} = vi.hoisted(() => ({ + copyTextToClipboardMock: vi.fn(), + logoutMock: vi.fn(), + shellOpenMock: vi.fn(), + startMock: vi.fn(), + statusMock: vi.fn(), +})) + +vi.mock('@tauri-apps/plugin-shell', () => ({ + open: shellOpenMock, +})) + +vi.mock('../../api/hahaOpenAIOAuth', () => ({ + hahaOpenAIOAuthApi: { + start: startMock, + status: statusMock, + logout: logoutMock, + }, +})) + +vi.mock('../chat/clipboard', () => ({ + copyTextToClipboard: copyTextToClipboardMock, +})) + +import { ChatGPTOfficialLogin } from './ChatGPTOfficialLogin' +import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' +import { useSettingsStore } from '../../stores/settingsStore' + +const initialOAuthState = useHahaOpenAIOAuthStore.getState() + +describe('ChatGPTOfficialLogin', () => { + beforeEach(() => { + vi.useRealTimers() + startMock.mockReset() + statusMock.mockReset() + logoutMock.mockReset() + shellOpenMock.mockReset() + copyTextToClipboardMock.mockReset() + useSettingsStore.setState({ locale: 'en' }) + useHahaOpenAIOAuthStore.setState({ + ...initialOAuthState, + status: null, + isPolling: false, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + act(() => { + useHahaOpenAIOAuthStore.getState().stopPolling() + useHahaOpenAIOAuthStore.setState(initialOAuthState) + }) + vi.useRealTimers() + cleanup() + vi.restoreAllMocks() + }) + + it('keeps an actionable authorization link when shell open fails', async () => { + const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state' + statusMock.mockResolvedValue({ loggedIn: false }) + startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' }) + shellOpenMock.mockRejectedValue(new Error('shell unavailable')) + copyTextToClipboardMock.mockResolvedValue(true) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + await screen.findByRole('button', { name: 'Sign in with ChatGPT' }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' })) + }) + + expect(shellOpenMock).toHaveBeenCalledWith(authorizeUrl) + expect(consoleErrorSpy).toHaveBeenCalledWith('[ChatGPTOfficialLogin] shellOpen failed:', expect.any(Error)) + expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' })) + }) + + expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true) + consoleErrorSpy.mockRestore() + }) +}) diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx index 4bbe0082..c973254f 100644 --- a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx @@ -1,13 +1,15 @@ // desktop/src/components/settings/ChatGPTOfficialLogin.tsx -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { open as shellOpen } from '@tauri-apps/plugin-shell' -import { LogIn, LogOut } from 'lucide-react' +import { Copy, LogIn, LogOut } from 'lucide-react' import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore' import { useTranslation } from '../../i18n' +import { copyTextToClipboard } from '../chat/clipboard' export function ChatGPTOfficialLogin() { const t = useTranslation() + const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState(null) const { status, isLoading, @@ -27,8 +29,10 @@ export function ChatGPTOfficialLogin() { const handleLogin = async () => { try { const { authorizeUrl } = await login() + setManualAuthorizeUrl(authorizeUrl) try { await shellOpen(authorizeUrl) + setManualAuthorizeUrl(null) startPolling() } catch (err) { console.error('[ChatGPTOfficialLogin] shellOpen failed:', err) @@ -41,11 +45,38 @@ export function ChatGPTOfficialLogin() { } } + const handleCopyAuthorizeUrl = async () => { + if (!manualAuthorizeUrl) return + const copied = await copyTextToClipboard(manualAuthorizeUrl) + if (copied) { + useHahaOpenAIOAuthStore.setState({ error: null }) + startPolling() + return + } + useHahaOpenAIOAuthStore.setState({ + error: t('settings.chatgptOfficialLogin.copyLinkFailed'), + }) + } + + const manualAuthorizeButton = manualAuthorizeUrl ? ( + + ) : null + if (status === null) { if (error) { return ( -
- {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ {manualAuthorizeButton}
) } @@ -99,6 +130,7 @@ export function ChatGPTOfficialLogin() { {t('settings.chatgptOfficialLogin.errorPrefix')}{error}
)} + {manualAuthorizeButton}
) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 0d57aa4a..7d955eb1 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -279,7 +279,9 @@ export const en = { 'settings.chatgptOfficialLogin.logoutProcessing': 'Signing out...', 'settings.chatgptOfficialLogin.loggedInPrefix': 'Signed in as', 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT account', - 'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization URL from server logs and open it manually.', + 'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization link and open it manually.', + 'settings.chatgptOfficialLogin.copyAuthorizeUrl': 'Copy authorization link', + 'settings.chatgptOfficialLogin.copyLinkFailed': 'Unable to copy authorization link.', 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth error: ', // Settings > Providers diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 392d090b..12fa0df1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -281,7 +281,9 @@ export const zh: Record = { 'settings.chatgptOfficialLogin.logoutProcessing': '正在退出...', 'settings.chatgptOfficialLogin.loggedInPrefix': '已登录', 'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT 账号', - 'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请从服务端日志复制授权链接并手动打开。', + 'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请复制授权链接并手动打开。', + 'settings.chatgptOfficialLogin.copyAuthorizeUrl': '复制授权链接', + 'settings.chatgptOfficialLogin.copyLinkFailed': '无法复制授权链接。', 'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:', // Settings > Providers From f497a08453b5ef1195492b27bcae16f9c3000076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 00:05:59 +0800 Subject: [PATCH 17/19] Clear stale ChatGPT OAuth fallback links Manual authorization links now behave as transient recovery state: a fresh login clears older URLs, a successful copy clears the exposed URL before polling, and logged-in status defensively drops any stale manual link. Constraint: OAuth authorization URLs should not survive beyond the active recovery action. Rejected: Keep the copy button after a successful copy | that leaves an expired authorization URL visible after later auth state changes. Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- src/components/settings/ChatGPTOfficialLogin.test.tsx src/stores/hahaOpenAIOAuthStore.test.ts Tested: cd desktop && bun run test -- src/components/settings/ChatGPTOfficialLogin.test.tsx src/stores/hahaOpenAIOAuthStore.test.ts src/__tests__/generalSettings.test.tsx --testNamePattern "ChatGPT|OpenAI OAuth|Providers tab|ChatGPTOfficialLogin|hahaOpenAIOAuthStore" Tested: cd desktop && bun run lint Tested: git diff --check --- .../settings/ChatGPTOfficialLogin.test.tsx | 29 ++++++++++++++++++- .../settings/ChatGPTOfficialLogin.tsx | 8 +++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx index 48f7b93b..d568f080 100644 --- a/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx @@ -90,7 +90,34 @@ describe('ChatGPTOfficialLogin', () => { }) expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl) + expect(useHahaOpenAIOAuthStore.getState().error).toBeNull() expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true) - consoleErrorSpy.mockRestore() + expect(screen.queryByText(/Unable to open browser/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Copy authorization link' })).not.toBeInTheDocument() + }) + + it('keeps the authorization link available when copy fails', async () => { + const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state' + statusMock.mockResolvedValue({ loggedIn: false }) + startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' }) + shellOpenMock.mockRejectedValue(new Error('shell unavailable')) + copyTextToClipboardMock.mockResolvedValue(false) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + await screen.findByRole('button', { name: 'Sign in with ChatGPT' }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' })) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' })) + }) + + expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl) + expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false) + expect(screen.getByText(/Unable to copy authorization link/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy authorization link' })).toBeInTheDocument() }) }) diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx index c973254f..087596b3 100644 --- a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx @@ -26,7 +26,14 @@ export function ChatGPTOfficialLogin() { return () => stopPolling() }, [fetchStatus, stopPolling]) + useEffect(() => { + if (status?.loggedIn) { + setManualAuthorizeUrl(null) + } + }, [status?.loggedIn]) + const handleLogin = async () => { + setManualAuthorizeUrl(null) try { const { authorizeUrl } = await login() setManualAuthorizeUrl(authorizeUrl) @@ -49,6 +56,7 @@ export function ChatGPTOfficialLogin() { if (!manualAuthorizeUrl) return const copied = await copyTextToClipboard(manualAuthorizeUrl) if (copied) { + setManualAuthorizeUrl(null) useHahaOpenAIOAuthStore.setState({ error: null }) startPolling() return From f77ab9b0a0ec65b061cb856c6bc051c23f61361f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 00:17:59 +0800 Subject: [PATCH 18/19] Stabilize workspace preview coverage timing The workspace long-file preview test regularly crosses ten seconds under V8 coverage instrumentation even though the behavior passes. Raising its explicit timeout keeps the coverage lane aligned with the runtime it already observes. Constraint: PR verification runs the full desktop test suite under coverage, which adds substantial highlighting overhead for this fixture. Rejected: Change the component rendering path | the failure was a coverage-mode test timeout, not a product regression. Confidence: high Scope-risk: narrow Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx --run --coverage --coverage.reporter=json-summary --coverage.reporter=lcov --coverage.reportsDirectory=/tmp/cc-haha-workspace-panel-coverage --testTimeout=20000 Tested: bun run check:native Tested: bun run check:coverage Tested: git diff --check --- desktop/src/components/workspace/WorkspacePanel.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index f5bfaf0c..b2d36688 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -970,7 +970,7 @@ describe('WorkspacePanel', () => { expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300') }) expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy() - }, 10_000) + }, 20_000) it('renders image previews from workspace files', async () => { await setWorkspaceState((state) => ({ From 5b0ab7aa8a3777070b6a8f03065d7cb4a9e9a319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 18:50:06 +0800 Subject: [PATCH 19/19] Route OpenAI OAuth callback through desktop server The ChatGPT Official OAuth start flow already generated the Codex /auth/callback redirect, but the desktop server only handled the older /callback/openai path. Real HTTP and UI E2E exposed the mismatch, so the server now routes the Codex callback path while retaining the legacy alias. Constraint: OpenAI Codex OAuth client code owns OPENAI_CODEX_REDIRECT_PATH. Rejected: Change the generated redirect back to /callback/openai | that would diverge from the shared OpenAI Codex OAuth client path. Confidence: high Scope-risk: narrow Tested: bun test src/server/__tests__/providers.test.ts src/server/__tests__/settings.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/proxy-transform.test.ts src/utils/__tests__/providerManagedEnvCompat.test.ts src/services/api/client.test.ts src/services/openaiAuth/fetch.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts src/server/__tests__/haha-openai-oauth-api.test.ts Tested: HTTP E2E passed at /var/folders/sx/hl8zlmxd7gx9b58_1t2zhp5r0000gn/T/cc-haha-openai-oauth-e2e-4uWfok/result.json. Tested: UI E2E passed at /var/folders/sx/hl8zlmxd7gx9b58_1t2zhp5r0000gn/T/cc-haha-openai-oauth-ui-e2e-ZyQ60q/result.json. --- .../__tests__/haha-openai-oauth-api.test.ts | 40 +++++++++++++++++++ src/server/api/haha-openai-oauth.ts | 3 +- src/server/index.ts | 6 ++- 3 files changed, 47 insertions(+), 2 deletions(-) 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/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/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) }