diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index 09bfa73e..da73944c 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -232,9 +232,11 @@ export class CodexBinaryResolver { private static async runResolve(): Promise { const override = process.env.CODEX_CLI_PATH?.trim(); + const shellOverride = getCachedShellEnv()?.CODEX_CLI_PATH?.trim(); const appManagedBinaryPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath(); const candidates = [ ...(override ? [override] : []), + ...(shellOverride && shellOverride !== override ? [shellOverride] : []), ...(appManagedBinaryPath ? [appManagedBinaryPath] : []), 'codex', ]; diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts index 3dbc9481..764d6ba3 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -135,6 +135,31 @@ describe('CodexBinaryResolver', () => { await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim); }); + it('uses CODEX_CLI_PATH from cached shell env before app-managed and PATH lookup', async () => { + setPlatform('darwin'); + delete process.env.CODEX_CLI_PATH; + process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; + const shellBinary = '/Users/tester/.local/bin/codex'; + const appManagedBinary = '/Users/tester/.agent-teams-ai/data/runtimes/codex/current/codex'; + getCachedShellEnvMock.mockReturnValue({ + CODEX_CLI_PATH: shellBinary, + PATH: '/opt/homebrew/bin:/usr/bin:/bin', + }); + resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(appManagedBinary); + + accessMock.mockImplementation((filePath) => { + if (filePath === shellBinary || filePath === appManagedBinary) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(shellBinary); + }); + it('prefers a verified app-managed Codex binary before PATH lookup', async () => { const appManagedBinary = 'C:\\Users\\tester\\AppData\\Roaming\\AgentTeams\\codex.exe'; const pathBinary = 'C:\\Program Files\\nodejs\\codex.cmd'; diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index b60783a9..50da552d 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -107,6 +107,10 @@ type AnthropicApiKeyVerifier = ( baseUrl?: string | null ) => Promise; +type CodexAccountSnapshotReader = Pick & { + refreshSnapshot?: CodexAccountFeatureFacade['refreshSnapshot']; +}; + interface ProviderStatusEnrichmentOptions { hydrateModelCatalog?: boolean; } @@ -307,7 +311,7 @@ async function checkCodexCliLoginStatus({ export class ProviderConnectionService { private static instance: ProviderConnectionService | null = null; - private codexAccountFeature: Pick | null = null; + private codexAccountFeature: CodexAccountSnapshotReader | null = null; private codexModelCatalogFeature: Pick | null = null; private readonly anthropicApiKeyVerificationCache = new Map< @@ -327,7 +331,7 @@ export class ProviderConnectionService { return ProviderConnectionService.instance; } - setCodexAccountFeature(feature: Pick | null): void { + setCodexAccountFeature(feature: CodexAccountSnapshotReader | null): void { this.codexAccountFeature = feature; } @@ -427,7 +431,9 @@ export class ProviderConnectionService { return env; } - const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const snapshot = await this.getCodexLaunchSnapshot(env, { + refreshRuntimeMissing: true, + }); applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, @@ -503,7 +509,9 @@ export class ProviderConnectionService { return env; } - const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const snapshot = await this.getCodexLaunchSnapshot(env, { + refreshRuntimeMissing: true, + }); applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, @@ -572,7 +580,9 @@ export class ProviderConnectionService { return null; } - const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const snapshot = await this.getCodexLaunchSnapshot(env, { + refreshRuntimeMissing: true, + }); const runtimeEnv = { ...env }; applyCodexRuntimeContextEnv(runtimeEnv, snapshot); const readiness = evaluateCodexLaunchReadiness({ @@ -681,7 +691,9 @@ export class ProviderConnectionService { return []; } - const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + const snapshot = await this.getCodexLaunchSnapshot(env, { + refreshRuntimeMissing: true, + }); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, managedAccount: snapshot.managedAccount, @@ -985,8 +997,13 @@ export class ProviderConnectionService { return CODEX_NATIVE_BACKEND_ID; } - private async getCodexAccountSnapshot(): Promise { + private async getCodexAccountSnapshot(options?: { + forceRefresh?: boolean; + }): Promise { if (this.codexAccountFeature) { + if (options?.forceRefresh && this.codexAccountFeature.refreshSnapshot) { + return this.codexAccountFeature.refreshSnapshot({ forceRefreshToken: true }); + } return this.codexAccountFeature.getSnapshot(); } @@ -1042,6 +1059,27 @@ export class ProviderConnectionService { }; } + private async getCodexLaunchSnapshot( + env: NodeJS.ProcessEnv, + options?: { refreshRuntimeMissing?: boolean } + ): Promise { + let snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + if (!options?.refreshRuntimeMissing || snapshot.appServerState !== 'runtime-missing') { + return snapshot; + } + + try { + snapshot = this.mergeCodexApiKeyAvailability( + await this.getCodexAccountSnapshot({ forceRefresh: true }), + env + ); + } catch { + // Keep the original runtime-missing snapshot so callers still report the concrete issue. + } + + return snapshot; + } + private async resolveCodexApiKeyValue( env: NodeJS.ProcessEnv, runtimeBackendOverride?: string | null, diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 6d6f37e2..5376160a 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -1,6 +1,8 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; + const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>(); const execCliMock = vi.fn< ( @@ -57,6 +59,67 @@ describe('ProviderConnectionService', () => { }; } + function createCodexSnapshot( + overrides: Partial = {} + ): CodexAccountSnapshotDto { + return { + preferredAuthMode: 'auto', + 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, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + 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', + ...overrides, + }; + } + + function createCodexRuntimeMissingSnapshot( + overrides: Partial = {} + ): CodexAccountSnapshotDto { + return createCodexSnapshot({ + effectiveAuthMode: null, + launchAllowed: false, + launchIssueMessage: 'Codex CLI not found', + launchReadinessState: 'runtime_missing', + appServerState: 'runtime-missing', + appServerStatusMessage: 'Codex CLI not found', + managedAccount: null, + requiresOpenaiAuth: null, + localAccountArtifactsPresent: false, + localActiveChatgptAccountPresent: false, + runtimeContext: { + binaryPath: null, + codexHome: null, + }, + ...overrides, + }); + } + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); @@ -922,6 +985,170 @@ describe('ProviderConnectionService', () => { expect(issue).toContain('Codex native requires OPENAI_API_KEY or CODEX_API_KEY'); }); + it('refreshes a runtime-missing Codex snapshot before blocking launch preflight', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const runtimeMissingSnapshot = createCodexRuntimeMissingSnapshot(); + const refreshSnapshot = vi.fn().mockResolvedValue( + createCodexSnapshot({ + effectiveAuthMode: 'api_key', + launchReadinessState: 'ready_api_key', + managedAccount: null, + }) + ); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue(runtimeMissingSnapshot), + refreshSnapshot, + }); + + const issue = await service.getConfiguredConnectionIssue( + { + CODEX_API_KEY: 'native-key', + }, + 'codex' + ); + + expect(issue).toBeNull(); + expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true }); + }); + + it('refreshes a runtime-missing Codex snapshot before mutating strict launch env', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const refreshSnapshot = vi.fn().mockResolvedValue(createCodexSnapshot()); + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue(createCodexRuntimeMissingSnapshot()), + refreshSnapshot, + }); + + const env = await service.applyConfiguredConnectionEnv( + { + OPENAI_API_KEY: 'ambient-openai-key', + CODEX_API_KEY: 'ambient-codex-key', + }, + 'codex' + ); + + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.CODEX_API_KEY).toBeUndefined(); + expect(env.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex'); + expect(env.CODEX_HOME).toBe('/Users/tester/.codex-custom'); + expect(env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD).toBe('chatgpt'); + expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true }); + }); + + it('refreshes a runtime-missing Codex snapshot before augmenting API-key launch env', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const refreshSnapshot = vi.fn().mockResolvedValue( + createCodexSnapshot({ + preferredAuthMode: 'api_key', + effectiveAuthMode: 'api_key', + launchReadinessState: 'ready_api_key', + managedAccount: null, + apiKey: { + available: true, + source: 'environment', + sourceLabel: 'Detected from CODEX_API_KEY', + }, + }) + ); + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue(createCodexRuntimeMissingSnapshot()), + refreshSnapshot, + }); + + const env = await service.augmentConfiguredConnectionEnv( + { + CODEX_API_KEY: 'native-key', + }, + 'codex' + ); + + expect(env.OPENAI_API_KEY).toBe('native-key'); + expect(env.CODEX_API_KEY).toBe('native-key'); + expect(env.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex'); + expect(env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD).toBe('api'); + expect(refreshSnapshot).toHaveBeenCalledWith({ forceRefreshToken: true }); + }); + + it('keeps the original runtime-missing issue when the forced Codex snapshot refresh fails', 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(createCodexRuntimeMissingSnapshot()), + refreshSnapshot: vi.fn().mockRejectedValue(new Error('refresh failed')), + }); + + const issue = await service.getConfiguredConnectionIssue({}, 'codex'); + + expect(issue).toBe('Codex CLI not found'); + }); + + it('refreshes a runtime-missing Codex snapshot before building forced launch args', 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(createCodexRuntimeMissingSnapshot()), + refreshSnapshot: vi.fn().mockResolvedValue(createCodexSnapshot()), + }); + + const args = await service.getConfiguredConnectionLaunchArgs( + {}, + 'codex', + 'codex-native', + 'codex' + ); + + expect(args).toEqual(['-c', 'forced_login_method="chatgpt"']); + }); + it('reports a pinned Codex ChatGPT mode as a missing active CLI login instead of flattening it to generic auth advice', 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 4f75d1ce..7a196661 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -385,6 +385,75 @@ describe('buildProviderAwareCliEnv', () => { ]); }); + it('passes Codex env refreshed by strict credential application into launch args and issue checks', async () => { + applyConfiguredConnectionEnvMock.mockImplementation( + async (env: NodeJS.ProcessEnv, providerId: string) => { + expect(providerId).toBe('codex'); + env.CODEX_CLI_PATH = '/Users/tester/.local/bin/codex'; + env.CODEX_HOME = '/Users/tester/.codex-custom'; + env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD = 'chatgpt'; + delete env.OPENAI_API_KEY; + delete env.CODEX_API_KEY; + return env; + } + ); + getConfiguredConnectionLaunchArgsMock.mockResolvedValue([ + '-c', + 'forced_login_method="chatgpt"', + ]); + + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + const result = await buildProviderAwareCliEnv({ + binaryPath: '/mock/claude-multimodel', + providerId: 'codex', + env: { + OPENAI_API_KEY: 'ambient-openai-key', + CODEX_API_KEY: 'ambient-codex-key', + }, + }); + + const launchArgsEnv = getConfiguredConnectionLaunchArgsMock.mock.calls[0]?.[0] as + | NodeJS.ProcessEnv + | undefined; + expect(launchArgsEnv).toBeDefined(); + expect(launchArgsEnv).toMatchObject({ + CODEX_CLI_PATH: '/Users/tester/.local/bin/codex', + CODEX_HOME: '/Users/tester/.codex-custom', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }); + expect(launchArgsEnv?.OPENAI_API_KEY).toBeUndefined(); + expect(launchArgsEnv?.CODEX_API_KEY).toBeUndefined(); + expect(getConfiguredConnectionLaunchArgsMock).toHaveBeenCalledWith( + launchArgsEnv, + 'codex', + undefined, + '/mock/claude-multimodel' + ); + const connectionIssuesEnv = getConfiguredConnectionIssuesMock.mock.calls[0]?.[0] as + | NodeJS.ProcessEnv + | undefined; + expect(connectionIssuesEnv).toBeDefined(); + expect(connectionIssuesEnv).toMatchObject({ + CODEX_CLI_PATH: '/Users/tester/.local/bin/codex', + CODEX_HOME: '/Users/tester/.codex-custom', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }); + expect(connectionIssuesEnv?.OPENAI_API_KEY).toBeUndefined(); + expect(connectionIssuesEnv?.CODEX_API_KEY).toBeUndefined(); + expect(getConfiguredConnectionIssuesMock).toHaveBeenCalledWith( + connectionIssuesEnv, + ['codex'], + { codex: undefined } + ); + expect(result.env.CODEX_CLI_PATH).toBe('/Users/tester/.local/bin/codex'); + expect(result.env.CODEX_HOME).toBe('/Users/tester/.codex-custom'); + expect(result.env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD).toBe('chatgpt'); + expect(result.env.OPENAI_API_KEY).toBeUndefined(); + expect(result.env.CODEX_API_KEY).toBeUndefined(); + expect(result.providerArgs).toEqual(['-c', 'forced_login_method="chatgpt"']); + }); + it('injects the verified app-managed OpenCode binary for OpenCode launches', async () => { const appManagedBinaryPath = path.join( process.cwd(), diff --git a/test/main/services/team/TeamProvisioningServiceCodexPreflight.test.ts b/test/main/services/team/TeamProvisioningServiceCodexPreflight.test.ts new file mode 100644 index 00000000..289e4a64 --- /dev/null +++ b/test/main/services/team/TeamProvisioningServiceCodexPreflight.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const buildProviderAwareCliEnvMock = vi.fn(); +const addTeamNotificationMock = vi.fn().mockResolvedValue(null); + +vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ + ClaudeBinaryResolver: { resolve: vi.fn() }, +})); + +vi.mock('@main/utils/shellEnv', () => ({ + resolveInteractiveShellEnv: vi.fn(), +})); + +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + buildProviderAwareCliEnvMock(...args), +})); + +vi.mock('@main/utils/childProcess', () => ({ + execCli: vi.fn(), + spawnCli: vi.fn(), + killProcessTree: vi.fn(), +})); + +vi.mock('@main/services/infrastructure/NotificationManager', () => ({ + NotificationManager: { + getInstance: () => ({ + addTeamNotification: addTeamNotificationMock, + }), + }, +})); + +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; + +type CodexProbeHarness = TeamProvisioningService & { + probeClaudeRuntime: ( + claudePath: string, + cwd: string, + env: NodeJS.ProcessEnv, + providerId: 'codex', + providerArgs: string[] + ) => Promise<{ warning?: string }>; + runProviderOneShotDiagnostic: ( + claudePath: string, + cwd: string, + env: NodeJS.ProcessEnv, + providerId: 'codex', + providerArgs: string[] + ) => Promise<{ warning?: string }>; +}; + +describe('TeamProvisioningService Codex create-team preflight', () => { + let tempRoot = ''; + + beforeEach(() => { + vi.clearAllMocks(); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-codex-preflight-')); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); + vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }); + buildProviderAwareCliEnvMock.mockImplementation( + async ({ env, providerId }: { env: NodeJS.ProcessEnv; providerId?: string }) => { + expect(providerId).toBe('codex'); + env.CODEX_CLI_PATH = '/Users/tester/.local/bin/codex'; + env.CODEX_HOME = '/Users/tester/.codex-custom'; + env.CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD = 'chatgpt'; + return { + env, + providerArgs: ['-c', 'forced_login_method="chatgpt"'], + connectionIssues: {}, + }; + } + ); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { force: true, recursive: true }); + }); + + it('uses refreshed Codex provider env for both runtime probe and deep one-shot preflight', async () => { + const service = new TeamProvisioningService(); + const harness = service as unknown as CodexProbeHarness; + const probeClaudeRuntime = vi.spyOn(harness, 'probeClaudeRuntime').mockResolvedValue({}); + const runProviderOneShotDiagnostic = vi + .spyOn(harness, 'runProviderOneShotDiagnostic') + .mockResolvedValue({}); + + const result = await service.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + modelVerificationMode: 'deep', + }); + + expect(result.ready).toBe(true); + expect(result.message).toBe('CLI is warmed up and ready to launch'); + expect(result.warnings?.join('\n') ?? '').not.toContain('Codex CLI not found'); + expect(probeClaudeRuntime).toHaveBeenCalledWith( + '/fake/claude', + tempRoot, + expect.objectContaining({ + CODEX_CLI_PATH: '/Users/tester/.local/bin/codex', + CODEX_HOME: '/Users/tester/.codex-custom', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }), + 'codex', + ['-c', 'forced_login_method="chatgpt"'] + ); + expect(runProviderOneShotDiagnostic).toHaveBeenCalledWith( + '/fake/claude', + tempRoot, + expect.objectContaining({ + CODEX_CLI_PATH: '/Users/tester/.local/bin/codex', + CODEX_HOME: '/Users/tester/.codex-custom', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }), + 'codex', + ['-c', 'forced_login_method="chatgpt"'] + ); + expect(buildProviderAwareCliEnvMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index 5523c79b..d65bb1d1 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -1440,6 +1440,49 @@ describe('runProviderPrepareDiagnostics', () => { }); }); + it('keeps concrete Codex runtime-missing warnings visible after model compatibility succeeds', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: TeamProviderId, + providerIds?: TeamProviderId[], + selectedModels?: string[], + limitContext?: boolean, + modelVerificationMode?: 'compatibility' | 'deep' + ) => Promise + >((_, __, ___, selectedModels, ____, modelVerificationMode) => { + if (selectedModels?.length === 1 && modelVerificationMode === 'compatibility') { + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch', + details: ['Selected model gpt-5.4 is available for launch.'], + }); + } + + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch (see notes)', + warnings: ['Codex CLI not found. Install Codex to use native account management.'], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4'], + prepareProvisioning, + }); + + expect(result.status).toBe('notes'); + expect(result.details).toEqual([ + 'Codex CLI not found. Install Codex to use native account management.', + '5.4 - available for launch', + ]); + expect(result.warnings).toEqual([ + 'Codex CLI not found. Install Codex to use native account management.', + ]); + }); + it('suppresses a generic runtime preflight failure when selected models later verify', async () => { const prepareProvisioning = vi.fn< (