fix(codex): improve runtime CLI discovery

This commit is contained in:
777genius 2026-05-18 20:04:50 +03:00
parent 8db61d4860
commit 67fbd1e681
11 changed files with 498 additions and 48 deletions

View file

@ -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<string>();
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<string | null> {
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<CodexRuntimeStatus> {
const binaryPath = resolvePathCodexBinary();
const binaryPath = await resolvePathCodexBinaryWithBestEffortEnv();
if (!binaryPath) {
return { installed: false, source: 'missing', state: 'idle' };
}

View file

@ -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<CliInstallationStatus> | null = null;
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
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<CliProviderId>(['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<void> {
statusCacheGeneration += 1;
cachedStatus = null;
statusInFlight = null;
providerStatusInFlight.clear();
ClaudeBinaryResolver.clearCache();
CodexBinaryResolver.clearCache();
service.invalidateStatusCache();
return { success: true, data: undefined };
}

View file

@ -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) {

View file

@ -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';

View file

@ -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 && (

View file

@ -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<Record<CliProviderId, boolean>>;
readonly disabled?: boolean;
readonly codexRuntimeStatus?: CodexRuntimeStatus | null;
readonly codexRuntimeStatusLoading?: boolean;
readonly onInstallCodexRuntime?: () => Promise<void> | void;
readonly onSelectBackend: (providerId: CliProviderId, backendId: string) => Promise<void> | void;
readonly onRefreshProvider?: (providerId: CliProviderId) => Promise<void> | 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
</Button>
{showCodexRuntimeInstallAction ? (
<Button
size="sm"
variant="outline"
disabled={codexActionBusy || codexRuntimeInstallBusy}
title={
codexRuntimeStatus?.error ??
codexRuntimeStatus?.progress?.detail ??
'Install Codex CLI into app data'
}
onClick={() => void onInstallCodexRuntime?.()}
>
{codexRuntimeInstallBusy ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<Download className="mr-1 size-3.5" />
)}
{getCodexRuntimeInstallLabel(codexRuntimeStatus)}
</Button>
) : null}
{codexLoginPending ? (
<>
<CodexLoginLinkCopyButton

View file

@ -21,8 +21,8 @@ import {
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
getProviderDisconnectAction,
isOpenCodeCatalogHydrating,
isConnectionManagedRuntimeProvider,
isOpenCodeCatalogHydrating,
shouldShowProviderConnectAction,
} from '@renderer/components/runtime/providerConnectionUi';
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
@ -201,9 +201,12 @@ export const CliStatusSection = (): React.JSX.Element | null => {
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' })
}

View file

@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className={`${DIALOG_WIDTH} max-w-[90vw]`}>
<DialogContent className={DIALOG_WIDTH}>
<DialogHeader>
<DialogTitle>Add Members</DialogTitle>
<DialogDescription>Add new members to {teamName}</DialogDescription>

View file

@ -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<NodeJS.ProcessEnv>>(() => 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', () => {

View file

@ -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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
}
function createMockIpcMain(): IpcMain & {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
} {
@ -84,6 +114,7 @@ function provider(overrides: Partial<CliProviderStatus> & { 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<typeof vi.fn>;
};
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<void>;
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<CliInstallationStatus>();
const freshRequest = deferred<CliInstallationStatus>();
service.getStatus
.mockReturnValueOnce(staleRequest.promise)
.mockReturnValueOnce(freshRequest.promise);
const firstInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise<
IpcResult<CliInstallationStatus>
>;
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<CliInstallationStatus>
>;
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<CliInstallationStatus>;
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<CliProviderStatus | null>();
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<CliInstallationStatus>;
expect(initial.success).toBe(true);
expect(initial.data?.authLoggedIn).toBe(false);
const staleProviderInvoke = ipcMain.invoke(
CLI_INSTALLER_GET_PROVIDER_STATUS,
'codex'
) as Promise<IpcResult<CliProviderStatus | null>>;
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<CliInstallationStatus>;
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<CliInstallationStatus>;
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'
);
});
});

View file

@ -27,6 +27,9 @@ interface StoreState {
openCodeRuntimeStatus: Record<string, unknown> | null;
openCodeRuntimeStatusLoading: boolean;
openCodeRuntimeError: string | null;
codexRuntimeStatus: Record<string, unknown> | null;
codexRuntimeStatusLoading: boolean;
codexRuntimeError: string | null;
bootstrapCliStatus: ReturnType<typeof vi.fn>;
fetchCliStatus: ReturnType<typeof vi.fn>;
fetchCliProviderStatus: ReturnType<typeof vi.fn>;
@ -35,6 +38,9 @@ interface StoreState {
fetchOpenCodeRuntimeStatus: ReturnType<typeof vi.fn>;
installOpenCodeRuntime: ReturnType<typeof vi.fn>;
invalidateOpenCodeRuntimeStatus: ReturnType<typeof vi.fn>;
fetchCodexRuntimeStatus: ReturnType<typeof vi.fn>;
installCodexRuntime: ReturnType<typeof vi.fn>;
invalidateCodexRuntimeStatus: ReturnType<typeof vi.fn>;
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';