diff --git a/src/features/codex-account/contracts/dto.ts b/src/features/codex-account/contracts/dto.ts index 27e45e94..341ab757 100644 --- a/src/features/codex-account/contracts/dto.ts +++ b/src/features/codex-account/contracts/dto.ts @@ -64,6 +64,11 @@ export interface CodexLoginStateDto { startedAt: string | null; } +export interface CodexRuntimeContextDto { + binaryPath: string | null; + codexHome: string | null; +} + export interface CodexAccountSnapshotDto { preferredAuthMode: CodexAccountAuthMode; effectiveAuthMode: CodexAccountEffectiveAuthMode; @@ -77,6 +82,7 @@ export interface CodexAccountSnapshotDto { requiresOpenaiAuth: boolean | null; localAccountArtifactsPresent?: boolean; localActiveChatgptAccountPresent?: boolean; + runtimeContext?: CodexRuntimeContextDto; login: CodexLoginStateDto; rateLimits: CodexRateLimitSnapshotDto | null; updatedAt: string; diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 8e95422a..9e5a54b6 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -48,6 +48,16 @@ interface CodexLastKnownRateLimits { observedAt: number; } +interface CodexRuntimeContext { + binaryPath: string | null; + codexHome: string | null; +} + +interface CodexLastKnownRuntimeContext { + payload: CodexRuntimeContext; + observedAt: number; +} + interface CodexSnapshotRefreshOptions { includeRateLimits: boolean; forceRefreshToken: boolean; @@ -130,6 +140,16 @@ function asRateLimits( }; } +function createRuntimeContext( + binaryPath: string | null | undefined, + codexHome: string | null | undefined +): CodexRuntimeContext { + return { + binaryPath: binaryPath?.trim() || null, + codexHome: codexHome?.trim() || null, + }; +} + function getPreferredAuthMode(configManager: { getConfig: () => { providerConnections: { @@ -236,6 +256,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null; private lastKnownAccount: CodexLastKnownAccount | null = null; private lastKnownRateLimits: CodexLastKnownRateLimits | null = null; + private lastKnownRuntimeContext: CodexLastKnownRuntimeContext | null = null; private mutationQueue: Promise = Promise.resolve(); private mutationQueueRelease: (() => void) | null = null; private activeMutationCount = 0; @@ -372,6 +393,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { this.pendingRefreshOptions = null; this.lastKnownAccount = null; this.lastKnownRateLimits = null; + this.lastKnownRuntimeContext = null; this.activeMutationCount = 0; if (this.mutationQueueRelease) { this.mutationQueueRelease(); @@ -441,6 +463,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { let appServerStatusMessage: string | null = null; let accountPayload = this.lastKnownAccount?.payload ?? null; let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null; + let runtimeContext = createRuntimeContext(binaryPath, null); try { const accountResult = await this.appServerClient.readAccount({ @@ -448,6 +471,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { env, refreshToken: options?.forceRefreshToken ?? false, }); + runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome); + if (runtimeContext.codexHome) { + this.lastKnownRuntimeContext = { + payload: runtimeContext, + observedAt: now, + }; + } const canReuseLastKnownManagedAccount = options?.forceRefreshToken !== true && localActiveChatgptAccountPresent && @@ -483,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { accountPayload = this.lastKnownAccount.payload; requiresOpenaiAuth = this.lastKnownAccount.payload.requiresOpenaiAuth; } + + if ( + this.lastKnownRuntimeContext && + now - this.lastKnownRuntimeContext.observedAt <= LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS && + this.lastKnownRuntimeContext.payload.binaryPath === binaryPath + ) { + runtimeContext = this.lastKnownRuntimeContext.payload; + } } let rateLimits: CodexRateLimitSnapshotDto | null = null; @@ -542,6 +580,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { requiresOpenaiAuth, localAccountArtifactsPresent, localActiveChatgptAccountPresent, + runtimeContext, login, rateLimits, updatedAt: new Date(now).toISOString(), diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 3c4a2abe..50af59de 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -62,6 +62,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial> = { }; const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY'; +const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH'; +const CODEX_HOME_ENV_VAR = 'CODEX_HOME'; const CODEX_NATIVE_BACKEND_ID = 'codex-native'; function isCodexExecBinary(binaryPath?: string | null): boolean { @@ -85,6 +87,21 @@ function buildCodexForcedLoginLaunchArgs( return ['--settings', JSON.stringify({ codex: { forced_login_method: loginMethod } })]; } +function applyCodexRuntimeContextEnv( + env: NodeJS.ProcessEnv, + snapshot: CodexAccountSnapshotDto +): void { + const binaryPath = snapshot.runtimeContext?.binaryPath?.trim(); + if (binaryPath) { + env[CODEX_CLI_PATH_ENV_VAR] = binaryPath; + } + + const codexHome = snapshot.runtimeContext?.codexHome?.trim(); + if (codexHome) { + env[CODEX_HOME_ENV_VAR] = codexHome; + } +} + export class ProviderConnectionService { private static instance: ProviderConnectionService | null = null; private codexAccountFeature: Pick | null = null; @@ -179,6 +196,7 @@ export class ProviderConnectionService { } const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, managedAccount: snapshot.managedAccount, @@ -239,6 +257,7 @@ export class ProviderConnectionService { } const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, managedAccount: snapshot.managedAccount, @@ -566,6 +585,10 @@ export class ProviderConnectionService { requiresOpenaiAuth: null, localAccountArtifactsPresent: false, localActiveChatgptAccountPresent: false, + runtimeContext: { + binaryPath: null, + codexHome: null, + }, login: { status: 'idle', error: null, diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index 0d0b847b..5ea072af 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -269,6 +269,10 @@ describe('createCodexAccountFeature', () => { source: 'environment', sourceLabel: 'Detected from OPENAI_API_KEY', }, + runtimeContext: { + binaryPath: '/usr/local/bin/codex', + codexHome: '/Users/test/.codex', + }, launchAllowed: true, launchReadinessState: 'ready_both', }); @@ -315,6 +319,10 @@ describe('createCodexAccountFeature', () => { type: 'chatgpt', email: 'user@example.com', }); + expect(degradedSnapshot.runtimeContext).toEqual({ + binaryPath: '/usr/local/bin/codex', + codexHome: '/Users/test/.codex', + }); expect(degradedSnapshot.launchAllowed).toBe(true); expect(logger.warn).not.toHaveBeenCalledWith( expect.stringContaining('false logout'), diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 308029f5..037dedb2 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -285,6 +285,122 @@ describe('ProviderConnectionService', () => { expect(result.CODEX_API_KEY).toBe('shell-openai-key'); }); + it('passes Codex runtime context while clearing API keys for ChatGPT launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + runtimeContext: { + binaryPath: '/opt/codex/bin/codex', + codexHome: '/Users/tester/.codex-custom', + }, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const result = await service.applyConfiguredConnectionEnv( + { + OPENAI_API_KEY: 'ambient-openai-key', + CODEX_API_KEY: 'ambient-codex-key', + }, + 'codex' + ); + + expect(result.OPENAI_API_KEY).toBeUndefined(); + expect(result.CODEX_API_KEY).toBeUndefined(); + expect(result.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex'); + expect(result.CODEX_HOME).toBe('/Users/tester/.codex-custom'); + }); + + it('keeps Codex runtime context when API-key mode mirrors credentials', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-openai-key', + }); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'api_key', + effectiveAuthMode: 'api_key', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }, + requiresOpenaiAuth: false, + runtimeContext: { + binaryPath: '/opt/codex/bin/codex.cmd', + codexHome: 'C:\\Users\\tester\\.codex', + }, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const result = await service.applyConfiguredConnectionEnv({}, 'codex'); + + expect(result.OPENAI_API_KEY).toBe('stored-openai-key'); + expect(result.CODEX_API_KEY).toBe('stored-openai-key'); + expect(result.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex.cmd'); + expect(result.CODEX_HOME).toBe('C:\\Users\\tester\\.codex'); + }); + it('accepts CODEX_API_KEY as the native external credential source for Codex', async () => { getCachedShellEnvMock.mockReturnValue({ CODEX_API_KEY: 'native-key',