From 67fbd1e681584de949d17954c622c1c5e9bd28d7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 18 May 2026 20:04:50 +0300 Subject: [PATCH] fix(codex): improve runtime CLI discovery --- .../CodexRuntimeInstallerService.ts | 32 +++- src/main/ipc/cliInstaller.ts | 30 +++- .../codexAppServer/CodexBinaryResolver.ts | 28 +-- .../__tests__/CodexBinaryResolver.test.ts | 30 ++++ .../components/dashboard/CliStatusBanner.tsx | 30 ++-- .../runtime/ProviderRuntimeSettingsDialog.tsx | 65 ++++++- .../settings/sections/CliStatusSection.tsx | 35 +++- .../team/dialogs/AddMemberDialog.tsx | 4 +- .../CodexRuntimeInstallerService.test.ts | 55 +++++- test/main/ipc/cliInstaller.test.ts | 162 +++++++++++++++++- .../cli/CliStatusVisibility.test.ts | 75 ++++++++ 11 files changed, 498 insertions(+), 48 deletions(-) diff --git a/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts index a1a79b7b..2016acab 100644 --- a/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts +++ b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts @@ -1,8 +1,9 @@ import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts'; import { execCli } from '@main/utils/childProcess'; +import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getAppDataPath } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; -import { getCachedShellEnv } from '@main/utils/shellEnv'; +import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { createHash, randomUUID } from 'crypto'; @@ -27,6 +28,7 @@ const MAX_TARBALL_BYTES = 160 * 1024 * 1024; const MAX_UNPACKED_BYTES = 650 * 1024 * 1024; const FETCH_TIMEOUT_MS = 60_000; const VERSION_TIMEOUT_MS = 10_000; +const PATH_SHELL_ENV_TIMEOUT_MS = 1_500; interface NpmPackageMetadata { name?: string; @@ -149,9 +151,16 @@ function splitPathEnv(pathValue: string | undefined): string[] { .filter(Boolean); } -function resolvePathCodexBinary(): string | null { +function resolvePathCodexBinary( + additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [] +): string | null { const shellEnv = getCachedShellEnv() ?? {}; - const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)]; + const pathEntries = [ + ...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)), + ...splitPathEnv(shellEnv.PATH), + ...splitPathEnv(buildMergedCliPath(null)), + ...splitPathEnv(process.env.PATH), + ]; const seen = new Set(); for (const entry of pathEntries) { const normalizedEntry = path.resolve(entry); @@ -169,6 +178,21 @@ function resolvePathCodexBinary(): string | null { return null; } +async function resolvePathCodexBinaryWithBestEffortEnv( + options: { shellEnvTimeoutMs?: number } = {} +): Promise { + const cachedCandidate = resolvePathCodexBinary(); + if (cachedCandidate) { + return cachedCandidate; + } + + const shellEnv = await resolveInteractiveShellEnvBestEffort({ + timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS, + fallbackEnv: process.env, + }); + return resolvePathCodexBinary([shellEnv]); +} + export function getCodexRuntimePlatformCandidates( platform: NodeJS.Platform = process.platform, arch: string = process.arch @@ -543,7 +567,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort { } private async getPathStatus(): Promise { - const binaryPath = resolvePathCodexBinary(); + const binaryPath = await resolvePathCodexBinaryWithBestEffortEnv(); if (!binaryPath) { return { installed: false, source: 'missing', state: 'idle' }; } diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index f8ab3028..aa27921d 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -18,6 +18,7 @@ import { import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; +import { CodexBinaryResolver } from '../services/infrastructure/codexAppServer'; import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver'; import type { CliInstallerService } from '../services'; @@ -35,6 +36,7 @@ let service: CliInstallerService; let statusInFlight: Promise | null = null; const providerStatusInFlight = new Map>(); let cachedStatus: { value: CliInstallationStatus; at: number } | null = null; +let statusCacheGeneration = 0; const STATUS_CACHE_TTL_MS = 5_000; const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set(['anthropic', 'codex', 'opencode']); @@ -104,14 +106,19 @@ async function handleGetStatus( if (!statusInFlight) { const startedAt = Date.now(); - statusInFlight = service + const generation = statusCacheGeneration; + const request = service .getStatus() .then((status) => { - cachedStatus = { value: status, at: Date.now() }; + if (generation === statusCacheGeneration) { + cachedStatus = { value: status, at: Date.now() }; + } return status; }) .catch((err) => { - cachedStatus = null; + if (generation === statusCacheGeneration) { + cachedStatus = null; + } throw err; }) .finally(() => { @@ -119,8 +126,11 @@ async function handleGetStatus( if (ms >= 2000) { logger.warn(`cliInstaller:getStatus slow ms=${ms}`); } - statusInFlight = null; + if (statusInFlight === request) { + statusInFlight = null; + } }); + statusInFlight = request; } const status = await statusInFlight; @@ -182,14 +192,19 @@ async function handleGetProviderStatus( return { success: true, data: status }; } + const generation = statusCacheGeneration; const request = service .getProviderStatus(providerId) .then((status) => { - patchCachedProviderStatus(status); + if (generation === statusCacheGeneration) { + patchCachedProviderStatus(status); + } return status; }) .finally(() => { - providerStatusInFlight.delete(providerId); + if (providerStatusInFlight.get(providerId) === request) { + providerStatusInFlight.delete(providerId); + } }); providerStatusInFlight.set(providerId, request); @@ -229,9 +244,12 @@ async function handleVerifyProviderModels( } function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult { + statusCacheGeneration += 1; cachedStatus = null; + statusInFlight = null; providerStatusInFlight.clear(); ClaudeBinaryResolver.clearCache(); + CodexBinaryResolver.clearCache(); service.invalidateStatusCache(); return { success: true, data: undefined }; } diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index 9e0fa47a..5649df12 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -134,21 +134,25 @@ export class CodexBinaryResolver { cacheVerifiedAt = Date.now(); return verifiedAppManagedBinaryPath; } - return null; - } + if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { + return null; + } + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; + } else { + if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { + return cachedBinaryPath; + } - if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { - return cachedBinaryPath; - } + const verified = await verifyBinary(cachedBinaryPath); + if (verified) { + cacheVerifiedAt = Date.now(); + return verified; + } - const verified = await verifyBinary(cachedBinaryPath); - if (verified) { - cacheVerifiedAt = Date.now(); - return verified; + cachedBinaryPath = undefined; + cacheVerifiedAt = 0; } - - cachedBinaryPath = undefined; - cacheVerifiedAt = 0; } if (!resolveInFlight) { diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts index 69538f10..73e521f4 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -90,6 +90,7 @@ describe('CodexBinaryResolver', () => { }); afterEach(() => { + vi.useRealTimers(); setPlatform(originalPlatform); process.env.PATH = originalPath; process.env.PATHEXT = originalPathExt; @@ -175,6 +176,35 @@ describe('CodexBinaryResolver', () => { await expect(CodexBinaryResolver.resolve()).resolves.toBe(appManagedBinary); }); + it('recovers a negative cache entry from PATH after the miss cache expires', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + setPlatform('darwin'); + process.env.PATH = '/usr/local/bin'; + const codexShim = path.posix.join('/usr/local/bin', 'codex'); + buildMergedCliPathMock.mockReturnValue('/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'); + + accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBeNull(); + + accessMock.mockImplementation((filePath) => { + if (filePath === codexShim) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + await expect(CodexBinaryResolver.resolve()).resolves.toBeNull(); + + vi.advanceTimersByTime(30_001); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); + }); + it('skips Windows PATH candidates that exist but cannot be launched', async () => { const blockedDir = 'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources'; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 78dd107a..b7cf7dc8 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -27,8 +27,8 @@ import { getProviderCredentialSummary, getProviderCurrentRuntimeSummary, getProviderDisconnectAction, - isOpenCodeCatalogHydrating, isConnectionManagedRuntimeProvider, + isOpenCodeCatalogHydrating, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; @@ -626,7 +626,6 @@ function shouldShowCodexInstallAction( !showSkeleton && !provider.authenticated && runtimeMissing && - codexRuntimeStatus?.source !== 'path' && !(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed') ); } @@ -1356,12 +1355,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => { }, [installCli]); const handleRefresh = useCallback(() => { - void refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); + void (async () => { + await invalidateCliStatus(); + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + })(); + }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]); const handleToggleProvidersCollapsed = useCallback(() => { setProvidersCollapsed((current) => { @@ -1438,9 +1440,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const handleProviderRefresh = useCallback( (providerId: CliProviderId) => { - void fetchCliProviderStatus(providerId); + void (async () => { + await invalidateCliStatus(); + await fetchCliProviderStatus(providerId); + })(); }, - [fetchCliProviderStatus] + [fetchCliProviderStatus, invalidateCliStatus] ); const handleProviderBackendChange = useCallback( @@ -1524,8 +1529,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { } providerStatusLoading={cliProviderStatusLoading} disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath} + codexRuntimeStatus={codexRuntimeStatus} + codexRuntimeStatusLoading={codexRuntimeStatusLoading} + onInstallCodexRuntime={() => installCodexRuntime()} onSelectBackend={handleProviderBackendChange} - onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + onRefreshProvider={handleProviderRefresh} onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })} /> {providerTerminal && renderCliStatus.binaryPath && ( diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index df869c13..8c5286cb 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -45,7 +45,7 @@ import { } from '@renderer/components/ui/select'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { useStore } from '@renderer/store'; -import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react'; +import { AlertTriangle, Download, Key, Link2, Loader2, Trash2 } from 'lucide-react'; import { formatProviderAuthMethodLabelForProvider, @@ -60,6 +60,7 @@ import { ProviderRuntimeBackendSelector, } from './ProviderRuntimeBackendSelector'; +import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts'; import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types'; import type { ApiKeyEntry } from '@shared/types/extensions'; @@ -80,6 +81,9 @@ interface Props { readonly projectPath?: string | null; readonly providerStatusLoading?: Partial>; readonly disabled?: boolean; + readonly codexRuntimeStatus?: CodexRuntimeStatus | null; + readonly codexRuntimeStatusLoading?: boolean; + readonly onInstallCodexRuntime?: () => Promise | void; readonly onSelectBackend: (providerId: CliProviderId, backendId: string) => Promise | void; readonly onRefreshProvider?: (providerId: CliProviderId) => Promise | void; readonly onRequestLogin?: (providerId: CliProviderId) => void; @@ -125,6 +129,33 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini'; } +function isCodexRuntimeInstalling( + status: CodexRuntimeStatus | null | undefined, + loading: boolean +): boolean { + return ( + loading || + status?.state === 'checking' || + status?.state === 'downloading' || + status?.state === 'installing' + ); +} + +function getCodexRuntimeInstallLabel(status: CodexRuntimeStatus | null | undefined): string { + switch (status?.state) { + case 'checking': + return 'Checking'; + case 'downloading': + return 'Downloading'; + case 'installing': + return 'Installing'; + case 'failed': + return 'Retry install'; + default: + return 'Install Codex CLI'; + } +} + function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null { const matches = apiKeys.filter((entry) => entry.envVarName === envVarName); return matches.find((entry) => entry.scope === 'user') ?? null; @@ -565,6 +596,9 @@ export const ProviderRuntimeSettingsDialog = ({ projectPath = null, providerStatusLoading = {}, disabled = false, + codexRuntimeStatus = null, + codexRuntimeStatusLoading = false, + onInstallCodexRuntime, onSelectBackend, onRefreshProvider, onRequestLogin, @@ -765,6 +799,15 @@ export const ProviderRuntimeSettingsDialog = ({ const connectionBusy = disabled || connectionLoading; const codexActionBusy = disabled || selectedProviderLoading || connectionSaving || codexAccount.loading; + const codexRuntimeInstallBusy = isCodexRuntimeInstalling( + codexRuntimeStatus, + codexRuntimeStatusLoading + ); + const showCodexRuntimeInstallAction = + selectedProvider?.providerId === 'codex' && + typeof onInstallCodexRuntime === 'function' && + codexConnection?.appServerState === 'runtime-missing' && + !(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed'); const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; const anthropicFastModeCapability = selectedProvider?.providerId === 'anthropic' @@ -1397,6 +1440,26 @@ export const ProviderRuntimeSettingsDialog = ({ > Refresh + {showCodexRuntimeInstallAction ? ( + + ) : null} {codexLoginPending ? ( <> { downloadTotal, installerError, completedVersion, + codexRuntimeStatus, + codexRuntimeStatusLoading, bootstrapCliStatus, fetchCliStatus, fetchCliProviderStatus, + installCodexRuntime, installCli, isBusy, cliStatusLoading, @@ -286,12 +289,25 @@ export const CliStatusSection = (): React.JSX.Element | null => { }, [installCli]); const handleRefresh = useCallback(() => { - void refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); + void (async () => { + await invalidateCliStatus(); + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + })(); + }, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]); + + const handleProviderRefresh = useCallback( + (providerId: CliProviderId) => { + void (async () => { + await invalidateCliStatus(); + await fetchCliProviderStatus(providerId); + })(); + }, + [fetchCliProviderStatus, invalidateCliStatus] + ); const handleProviderLogout = useCallback( async (providerId: CliProviderId) => { @@ -673,8 +689,11 @@ export const CliStatusSection = (): React.JSX.Element | null => { initialProviderId={manageProviderId} providerStatusLoading={cliProviderStatusLoading} disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading} + codexRuntimeStatus={codexRuntimeStatus} + codexRuntimeStatusLoading={codexRuntimeStatusLoading} + onInstallCodexRuntime={() => installCodexRuntime()} onSelectBackend={handleRuntimeBackendChange} - onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)} + onRefreshProvider={handleProviderRefresh} onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' }) } diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index bde75750..9d4ef77e 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -51,7 +51,7 @@ interface AddMemberDialogProps { }[]; } -const DIALOG_WIDTH = 'w-[720px]'; +const DIALOG_WIDTH = 'max-w-[52rem]'; function deriveExistingWorktreeDefault( existingMembers: AddMemberDialogProps['existingMembers'] @@ -179,7 +179,7 @@ export const AddMemberDialog = ({ return ( - + Add Members Add new members to {teamName} diff --git a/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts b/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts index 1dbd0cdc..41af6fd5 100644 --- a/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts +++ b/test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts @@ -1,17 +1,30 @@ import { createHash } from 'crypto'; -import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; import { gzipSync } from 'zlib'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const execCliMock = vi.hoisted(() => vi.fn()); +const buildMergedCliPathMock = vi.hoisted(() => vi.fn(() => process.env.PATH ?? '')); +const getCachedShellEnvMock = vi.hoisted(() => vi.fn<() => NodeJS.ProcessEnv | null>(() => null)); +const resolveInteractiveShellEnvBestEffortMock = vi.hoisted(() => + vi.fn<(...args: unknown[]) => Promise>(() => Promise.resolve({})) +); vi.mock('@main/utils/childProcess', () => ({ execCli: execCliMock, })); +vi.mock('@main/utils/cliPathMerge', () => ({ + buildMergedCliPath: buildMergedCliPathMock, +})); +vi.mock('@main/utils/shellEnv', () => ({ + getCachedShellEnv: getCachedShellEnvMock, + resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock, +})); import { + createCodexRuntimeInstallerFeature, extractCodexRuntimePackageFilesFromTarball, getCodexRuntimePlatformCandidates, resolveAppManagedCodexRuntimeBinaryPath, @@ -21,6 +34,7 @@ import { import { setAppDataBasePath } from '@main/utils/pathDecoder'; let tempRoot: string | null = null; +const originalPath = process.env.PATH; function writeOctal(header: Buffer, offset: number, length: number, value: number): void { const encoded = value @@ -65,10 +79,17 @@ describe('CodexRuntimeInstallerService resolver', () => { setAppDataBasePath(tempRoot); execCliMock.mockReset(); execCliMock.mockResolvedValue({ stdout: 'codex-cli 1.0.0\n', stderr: '' }); + buildMergedCliPathMock.mockReset(); + buildMergedCliPathMock.mockImplementation(() => process.env.PATH ?? ''); + getCachedShellEnvMock.mockReset(); + getCachedShellEnvMock.mockReturnValue(null); + resolveInteractiveShellEnvBestEffortMock.mockReset(); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({}); }); afterEach(async () => { setAppDataBasePath(null); + process.env.PATH = originalPath; if (tempRoot) { await rm(tempRoot, { recursive: true, force: true }); tempRoot = null; @@ -168,6 +189,38 @@ describe('CodexRuntimeInstallerService resolver', () => { await expect(resolveVerifiedAppManagedCodexRuntimeBinaryPath()).resolves.toBeNull(); }); + + it('detects a PATH Codex binary from best-effort shell env when process PATH is cold', async () => { + const binDir = path.join(tempRoot!, 'shell-bin'); + const executableName = process.platform === 'win32' ? 'codex.exe' : 'codex'; + const binaryPath = path.join(binDir, executableName); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- test uses isolated temp dir + await mkdir(binDir, { recursive: true }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- test uses isolated temp dir + await writeFile(binaryPath, 'binary'); + if (process.platform !== 'win32') { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- test uses isolated temp dir + await chmod(binaryPath, 0o755); + } + process.env.PATH = '/usr/bin:/bin'; + buildMergedCliPathMock.mockReturnValue('/usr/bin:/bin'); + resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({ PATH: binDir }); + + const status = await createCodexRuntimeInstallerFeature().getStatus(); + + expect(status).toMatchObject({ + installed: true, + binaryPath, + source: 'path', + state: 'ready', + }); + expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackEnv: process.env, + timeoutMs: 1_500, + }) + ); + }); }); describe('CodexRuntimeInstallerService package safety helpers', () => { diff --git a/test/main/ipc/cliInstaller.test.ts b/test/main/ipc/cliInstaller.test.ts index 9a8fee01..bf841a97 100644 --- a/test/main/ipc/cliInstaller.test.ts +++ b/test/main/ipc/cliInstaller.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +const claudeBinaryResolverClearCacheMock = vi.hoisted(() => vi.fn()); +const codexBinaryResolverClearCacheMock = vi.hoisted(() => vi.fn()); + vi.mock('@shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), @@ -9,6 +12,18 @@ vi.mock('@shared/utils/logger', () => ({ }), })); +vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ + ClaudeBinaryResolver: { + clearCache: claudeBinaryResolverClearCacheMock, + }, +})); + +vi.mock('@main/services/infrastructure/codexAppServer', () => ({ + CodexBinaryResolver: { + clearCache: codexBinaryResolverClearCacheMock, + }, +})); + import { initializeCliInstallerHandlers, registerCliInstallerHandlers, @@ -16,6 +31,7 @@ import { import { CLI_INSTALLER_GET_PROVIDER_STATUS, CLI_INSTALLER_GET_STATUS, + CLI_INSTALLER_INVALIDATE_STATUS, } from '@preload/constants/ipcChannels'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; @@ -25,6 +41,20 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron'; type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown; +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + function createMockIpcMain(): IpcMain & { invoke: (channel: string, ...args: unknown[]) => Promise; } { @@ -84,6 +114,7 @@ function provider(overrides: Partial & { providerId: CliProvi } function status(providers: CliProviderStatus[]): CliInstallationStatus { + const authenticatedProvider = providers.find((entry) => entry.authenticated) ?? null; return { flavor: 'agent_teams_orchestrator', displayName: 'Multimodel runtime', @@ -96,9 +127,9 @@ function status(providers: CliProviderStatus[]): CliInstallationStatus { launchError: null, latestVersion: null, updateAvailable: false, - authLoggedIn: false, + authLoggedIn: authenticatedProvider !== null, authStatusChecking: false, - authMethod: null, + authMethod: authenticatedProvider?.authMethod ?? null, providers, }; } @@ -114,7 +145,7 @@ describe('cliInstaller IPC handlers', () => { invalidateStatusCache: ReturnType; }; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); ipcMain = createMockIpcMain(); service = { @@ -127,6 +158,8 @@ describe('cliInstaller IPC handlers', () => { }; initializeCliInstallerHandlers(service as unknown as CliInstallerService); registerCliInstallerHandlers(ipcMain); + await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS); + vi.clearAllMocks(); }); it('does not let explicit hidden Gemini refresh poison cached frontend auth status', async () => { @@ -172,4 +205,127 @@ describe('cliInstaller IPC handlers', () => { expect(cached.data?.authLoggedIn).toBe(false); expect(cached.data?.authMethod).toBeNull(); }); + + it('clears Claude and Codex binary resolver caches when status is invalidated', async () => { + const result = (await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS)) as IpcResult; + + expect(result.success).toBe(true); + expect(claudeBinaryResolverClearCacheMock).toHaveBeenCalledTimes(1); + expect(codexBinaryResolverClearCacheMock).toHaveBeenCalledTimes(1); + expect(service.invalidateStatusCache).toHaveBeenCalledTimes(1); + }); + + it('does not reuse or recache a status request that was in flight before invalidation', async () => { + const staleStatus = status([ + provider({ + providerId: 'codex', + verificationState: 'error', + statusMessage: 'Codex CLI not found', + }), + ]); + const freshStatus = status([ + provider({ + providerId: 'codex', + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + statusMessage: 'ChatGPT account ready', + }), + ]); + const staleRequest = deferred(); + const freshRequest = deferred(); + service.getStatus + .mockReturnValueOnce(staleRequest.promise) + .mockReturnValueOnce(freshRequest.promise); + + const firstInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise< + IpcResult + >; + await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(1)); + + await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS); + const secondInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise< + IpcResult + >; + await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(2)); + + staleRequest.resolve(staleStatus); + freshRequest.resolve(freshStatus); + + await expect(firstInvoke).resolves.toMatchObject({ + success: true, + data: { authLoggedIn: false }, + }); + await expect(secondInvoke).resolves.toMatchObject({ + success: true, + data: { authLoggedIn: true, authMethod: 'chatgpt' }, + }); + + const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; + + expect(service.getStatus).toHaveBeenCalledTimes(2); + expect(cached.success).toBe(true); + expect(cached.data?.authLoggedIn).toBe(true); + expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready'); + }); + + it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => { + const staleProviderRequest = deferred(); + service.getStatus + .mockResolvedValueOnce( + status([ + provider({ providerId: 'anthropic' }), + provider({ providerId: 'codex', statusMessage: 'Checking...' }), + ]) + ) + .mockResolvedValueOnce( + status([ + provider({ providerId: 'anthropic' }), + provider({ + providerId: 'codex', + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + statusMessage: 'ChatGPT account ready', + }), + ]) + ); + service.getProviderStatus.mockReturnValueOnce(staleProviderRequest.promise); + + const initial = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; + expect(initial.success).toBe(true); + expect(initial.data?.authLoggedIn).toBe(false); + + const staleProviderInvoke = ipcMain.invoke( + CLI_INSTALLER_GET_PROVIDER_STATUS, + 'codex' + ) as Promise>; + await vi.waitFor(() => expect(service.getProviderStatus).toHaveBeenCalledTimes(1)); + + await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS); + const fresh = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; + expect(fresh.success).toBe(true); + expect(fresh.data?.authLoggedIn).toBe(true); + + staleProviderRequest.resolve( + provider({ + providerId: 'codex', + verificationState: 'error', + statusMessage: 'Codex CLI not found', + }) + ); + await expect(staleProviderInvoke).resolves.toMatchObject({ + success: true, + data: { statusMessage: 'Codex CLI not found' }, + }); + + const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; + + expect(service.getStatus).toHaveBeenCalledTimes(2); + expect(cached.success).toBe(true); + expect(cached.data?.authLoggedIn).toBe(true); + expect(cached.data?.providers.find((entry) => entry.providerId === 'codex')?.statusMessage).toBe( + 'ChatGPT account ready' + ); + }); }); diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index a5c3d73a..bcda1da9 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -27,6 +27,9 @@ interface StoreState { openCodeRuntimeStatus: Record | null; openCodeRuntimeStatusLoading: boolean; openCodeRuntimeError: string | null; + codexRuntimeStatus: Record | null; + codexRuntimeStatusLoading: boolean; + codexRuntimeError: string | null; bootstrapCliStatus: ReturnType; fetchCliStatus: ReturnType; fetchCliProviderStatus: ReturnType; @@ -35,6 +38,9 @@ interface StoreState { fetchOpenCodeRuntimeStatus: ReturnType; installOpenCodeRuntime: ReturnType; invalidateOpenCodeRuntimeStatus: ReturnType; + fetchCodexRuntimeStatus: ReturnType; + installCodexRuntime: ReturnType; + invalidateCodexRuntimeStatus: ReturnType; appConfig: { general: { multimodelEnabled: boolean; @@ -328,6 +334,9 @@ describe('CLI status visibility during completed install state', () => { storeState.openCodeRuntimeStatus = null; storeState.openCodeRuntimeStatusLoading = false; storeState.openCodeRuntimeError = null; + storeState.codexRuntimeStatus = null; + storeState.codexRuntimeStatusLoading = false; + storeState.codexRuntimeError = null; storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined); storeState.fetchCliProviderStatus = vi.fn().mockResolvedValue(undefined); @@ -336,6 +345,9 @@ describe('CLI status visibility during completed install state', () => { storeState.fetchOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined); storeState.installOpenCodeRuntime = vi.fn().mockResolvedValue(undefined); storeState.invalidateOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined); + storeState.fetchCodexRuntimeStatus = vi.fn().mockResolvedValue(undefined); + storeState.installCodexRuntime = vi.fn().mockResolvedValue(undefined); + storeState.invalidateCodexRuntimeStatus = vi.fn().mockResolvedValue(undefined); storeState.appConfig = { general: { multimodelEnabled: true, @@ -497,6 +509,69 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('shows a Codex install action on the dashboard when the Codex native runtime is missing', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.codexRuntimeStatus = { + installed: true, + source: 'path', + state: 'ready', + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.125.0', + }; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: false, + providers: [ + createCodexNativeRolloutProvider({ + authenticated: false, + authMethod: null, + verificationState: 'error', + state: 'runtime-missing', + available: false, + selectable: false, + statusMessage: + 'Codex CLI not found. Install Codex to use native account management.', + detailMessage: 'Codex native runtime is missing.', + models: [], + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex'); + expect(host.textContent).toContain('Install'); + + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Install' + ); + expect(installButton).not.toBeUndefined(); + + await act(async () => { + installButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(storeState.installCodexRuntime).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows OpenCode app-managed install progress on the dashboard provider card', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle';