diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 2e4b3cca..36eef0d1 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -10,7 +10,11 @@ import { tmpdir } from 'os'; import path from 'path'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; -import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; +import { + buildProviderAwareCliEnv, + getAggregateProviderStatusStoredCredentialAllowlist, + getProviderStatusStoredCredentialAllowlist, +} from './providerAwareCliEnv'; import { providerConnectionService } from './ProviderConnectionService'; import type { @@ -838,12 +842,15 @@ export class ClaudeMultimodelBridgeService { } private async buildCliEnv( - binaryPath: string + binaryPath: string, + options: { allowedStoredApiKeyEnvVarNames?: readonly string[] } = {} ): Promise>> { return buildProviderAwareCliEnv({ binaryPath, allowStoredApiKeyDecryption: false, - allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'], + allowedStoredApiKeyEnvVarNames: options.allowedStoredApiKeyEnvVarNames ?? [ + 'ANTHROPIC_AUTH_TOKEN', + ], }); } @@ -855,8 +862,7 @@ export class ClaudeMultimodelBridgeService { binaryPath, providerId, allowStoredApiKeyDecryption: false, - allowedStoredApiKeyEnvVarNames: - providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined, + allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist(providerId), }); } @@ -1744,7 +1750,9 @@ export class ClaudeMultimodelBridgeService { ); } - const { env, connectionIssues } = await this.buildCliEnv(binaryPath); + const { env, connectionIssues } = await this.buildCliEnv(binaryPath, { + allowedStoredApiKeyEnvVarNames: getAggregateProviderStatusStoredCredentialAllowlist(), + }); try { const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], { diff --git a/src/main/services/runtime/CliProviderModelAvailabilityService.ts b/src/main/services/runtime/CliProviderModelAvailabilityService.ts index b9c8b520..4c23438c 100644 --- a/src/main/services/runtime/CliProviderModelAvailabilityService.ts +++ b/src/main/services/runtime/CliProviderModelAvailabilityService.ts @@ -3,7 +3,10 @@ import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; -import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; +import { + buildProviderAwareCliEnv, + getProviderStatusStoredCredentialAllowlist, +} from './providerAwareCliEnv'; import { buildProviderModelProbeArgs, classifyProviderModelProbeFailure, @@ -194,8 +197,9 @@ export class CliProviderModelAvailabilityService { binaryPath: context.binaryPath, providerId: context.provider.providerId, allowStoredApiKeyDecryption: false, - allowedStoredApiKeyEnvVarNames: - context.provider.providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined, + allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist( + context.provider.providerId + ), }).then((result) => ({ env: result.env, providerArgs: result.providerArgs ?? [], diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index a6bdd0ae..7dfa53dd 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -12,6 +12,14 @@ import type { CliProviderId, TeamProviderId } from '@shared/types'; type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE'; +const PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = { + anthropic: ['ANTHROPIC_AUTH_TOKEN'], + codex: ['OPENAI_API_KEY'], +} as const; +const AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = [ + 'ANTHROPIC_AUTH_TOKEN', + 'OPENAI_API_KEY', +] as const; export interface ProviderAwareCliEnvOptions { binaryPath?: string | null; @@ -30,6 +38,20 @@ export interface ProviderAwareCliEnvResult { providerArgs: string[]; } +export function getProviderStatusStoredCredentialAllowlist( + providerId: ProviderEnvTargetId +): readonly string[] | undefined { + if (providerId === 'anthropic' || providerId === 'codex') { + return PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST[providerId]; + } + + return undefined; +} + +export function getAggregateProviderStatusStoredCredentialAllowlist(): readonly string[] { + return AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST; +} + function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void { delete env[ELECTRON_RUN_AS_NODE_ENV]; } diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 856e0eb8..1a2daa5a 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -57,6 +57,16 @@ vi.mock('@main/services/runtime/ProviderConnectionService', () => ({ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ buildProviderAwareCliEnv: (...args: Parameters) => buildProviderAwareCliEnvMock(...args), + getAggregateProviderStatusStoredCredentialAllowlist: () => [ + 'ANTHROPIC_AUTH_TOKEN', + 'OPENAI_API_KEY', + ], + getProviderStatusStoredCredentialAllowlist: (providerId?: string) => + providerId === 'anthropic' + ? ['ANTHROPIC_AUTH_TOKEN'] + : providerId === 'codex' + ? ['OPENAI_API_KEY'] + : undefined, })); describe('ClaudeMultimodelBridgeService', () => { @@ -249,6 +259,14 @@ describe('ClaudeMultimodelBridgeService', () => { projectId: 'demo-project', }, }); + + const aggregateEnvBuild = buildProviderAwareCliEnvMock.mock.calls.find( + ([options]) => options.providerId === undefined + ); + expect(aggregateEnvBuild?.[0]).toMatchObject({ + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + }); }); it('falls back to provider-scoped full runtime status without probing Gemini', async () => { @@ -950,6 +968,18 @@ describe('ClaudeMultimodelBridgeService', () => { modelCatalogRefreshState: 'ready', }); expect(hydratedCodex?.modelCatalog?.models.map((model) => model.id)).toEqual(['gpt-5.4']); + + const codexEnvBuilds = buildProviderAwareCliEnvMock.mock.calls.filter( + ([options]) => options.providerId === 'codex' + ); + expect(codexEnvBuilds.length).toBeGreaterThanOrEqual(2); + for (const [options] of codexEnvBuilds) { + expect(options).toMatchObject({ + providerId: 'codex', + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }); + } }); it('promotes OpenCode auth when full catalog hydration proves built-in free access', async () => { @@ -1839,6 +1869,44 @@ describe('ClaudeMultimodelBridgeService', () => { ); }); + it('allows the stored Codex API key for Codex status checks', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + providers: { + codex: { + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { teamLaunch: true, oneShot: true }, + }, + }, + }), + stderr: '', + exitCode: 0, + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex'); + + expect(provider).toMatchObject({ + providerId: 'codex', + authenticated: true, + authMethod: 'api_key', + }); + expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'codex', + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }) + ); + }); + it('falls back conservatively when the runtime omits extension capability metadata', async () => { execCliMock.mockResolvedValue({ stdout: JSON.stringify({ diff --git a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts index 541cebb7..74b37ebb 100644 --- a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts +++ b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts @@ -11,6 +11,12 @@ vi.mock('@main/utils/childProcess', () => ({ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ buildProviderAwareCliEnv: (...args: Parameters) => buildProviderAwareCliEnvMock(...args), + getProviderStatusStoredCredentialAllowlist: (providerId?: string) => + providerId === 'anthropic' + ? ['ANTHROPIC_AUTH_TOKEN'] + : providerId === 'codex' + ? ['OPENAI_API_KEY'] + : undefined, })); import { @@ -186,4 +192,25 @@ describe('CliProviderModelAvailabilityService', () => { ); }); }); + + it('allows stored Codex API-key access for model probes', async () => { + buildProviderAwareCliEnvMock.mockResolvedValue({ + env: { HOME: '/Users/tester' }, + connectionIssues: {}, + }); + execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' }); + + const service = new CliProviderModelAvailabilityService(); + service.getSnapshot(createContext(['gpt-5.4'])); + + await vi.waitFor(() => { + expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'codex', + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }) + ); + }); + }); }); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 2edded8e..3060ac23 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -1200,6 +1200,115 @@ describe('ProviderConnectionService', () => { expect(result.CODEX_API_KEY).toBe('openai-stored-key'); }); + it('mirrors a stored Codex OpenAI key when metadata-only status checks allow it', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'openai-stored-key', + }); + const hasPreferred = vi.fn().mockResolvedValue(true); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + hasPreferred, + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const result = await service.applyConfiguredConnectionEnv({}, 'codex', undefined, { + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'], + }); + + expect(hasPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(result.OPENAI_API_KEY).toBe('openai-stored-key'); + expect(result.CODEX_API_KEY).toBe('openai-stored-key'); + }); + + it('does not mirror a stored Codex OpenAI key during metadata-only checks without allowlist', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'openai-stored-key', + }); + const hasPreferred = vi.fn().mockResolvedValue(true); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + hasPreferred, + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const result = await service.applyConfiguredConnectionEnv({}, 'codex', undefined, { + allowStoredApiKeyDecryption: false, + }); + + expect(hasPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(lookupPreferred).not.toHaveBeenCalled(); + expect(result.OPENAI_API_KEY).toBeUndefined(); + expect(result.CODEX_API_KEY).toBeUndefined(); + }); + + it('applies aggregate metadata-only allowlist without decrypting unrelated provider keys', async () => { + const lookupPreferred = vi.fn(async (envVarName: string) => { + if (envVarName === 'ANTHROPIC_AUTH_TOKEN') { + return { envVarName, value: 'anthropic-compatible-token' }; + } + if (envVarName === 'OPENAI_API_KEY') { + return { envVarName, value: 'openai-stored-key' }; + } + if (envVarName === 'GEMINI_API_KEY') { + return { envVarName, value: 'gemini-stored-key' }; + } + return null; + }); + const hasPreferred = vi.fn(async (envVarName: string) => envVarName === 'OPENAI_API_KEY'); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + hasPreferred, + lookupPreferred, + } as never, + { + getConfig: () => + createConfig('auto', { + enabled: true, + baseUrl: 'http://localhost:1234', + }), + } as never + ); + + const result = await service.applyAllConfiguredConnectionEnv( + {}, + { + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + } + ); + + expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:1234'); + expect(result.ANTHROPIC_AUTH_TOKEN).toBe('anthropic-compatible-token'); + expect(result.OPENAI_API_KEY).toBe('openai-stored-key'); + expect(result.CODEX_API_KEY).toBe('openai-stored-key'); + expect(result.GEMINI_API_KEY).toBeUndefined(); + expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_AUTH_TOKEN'); + expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY'); + expect(lookupPreferred).not.toHaveBeenCalledWith('GEMINI_API_KEY'); + expect(lookupPreferred).not.toHaveBeenCalledWith('ANTHROPIC_API_KEY'); + }); + it('keeps ambient OpenAI credentials for native Codex launches', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 7d4c3265..13870574 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -107,6 +107,25 @@ describe('buildProviderAwareCliEnv', () => { }); }); + it('returns narrow provider status stored credential allowlists', async () => { + const { + getAggregateProviderStatusStoredCredentialAllowlist, + getProviderStatusStoredCredentialAllowlist, + } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + + expect(getProviderStatusStoredCredentialAllowlist('anthropic')).toEqual([ + 'ANTHROPIC_AUTH_TOKEN', + ]); + expect(getProviderStatusStoredCredentialAllowlist('codex')).toEqual(['OPENAI_API_KEY']); + expect(getProviderStatusStoredCredentialAllowlist('gemini')).toBeUndefined(); + expect(getProviderStatusStoredCredentialAllowlist('opencode')).toBeUndefined(); + expect(getProviderStatusStoredCredentialAllowlist(undefined)).toBeUndefined(); + expect(getAggregateProviderStatusStoredCredentialAllowlist()).toEqual([ + 'ANTHROPIC_AUTH_TOKEN', + 'OPENAI_API_KEY', + ]); + }); + it('builds provider-pinned CLI env and returns provider-specific issues', async () => { getConfiguredConnectionIssuesMock.mockResolvedValue({ anthropic: 'missing key', @@ -234,6 +253,21 @@ describe('buildProviderAwareCliEnv', () => { expect(applyAllConfiguredConnectionEnvMock).not.toHaveBeenCalled(); }); + it('passes a stored API key decrypt allowlist through shared env building', async () => { + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + await buildProviderAwareCliEnv({ + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + }); + + expect(applyAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(expect.any(Object), { + allowStoredApiKeyDecryption: false, + allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'], + }); + expect(applyConfiguredConnectionEnvMock).not.toHaveBeenCalled(); + }); + it('builds shared env for generic CLI launches when no provider is specified', async () => { const { buildProviderAwareCliEnv } = await import('../../../../src/main/services/runtime/providerAwareCliEnv');