fix(codex): improve runtime CLI discovery
This commit is contained in:
parent
8db61d4860
commit
67fbd1e681
11 changed files with 498 additions and 48 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
|
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
|
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
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 { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
@ -27,6 +28,7 @@ const MAX_TARBALL_BYTES = 160 * 1024 * 1024;
|
||||||
const MAX_UNPACKED_BYTES = 650 * 1024 * 1024;
|
const MAX_UNPACKED_BYTES = 650 * 1024 * 1024;
|
||||||
const FETCH_TIMEOUT_MS = 60_000;
|
const FETCH_TIMEOUT_MS = 60_000;
|
||||||
const VERSION_TIMEOUT_MS = 10_000;
|
const VERSION_TIMEOUT_MS = 10_000;
|
||||||
|
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
|
||||||
|
|
||||||
interface NpmPackageMetadata {
|
interface NpmPackageMetadata {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -149,9 +151,16 @@ function splitPathEnv(pathValue: string | undefined): string[] {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePathCodexBinary(): string | null {
|
function resolvePathCodexBinary(
|
||||||
|
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
|
||||||
|
): string | null {
|
||||||
const shellEnv = getCachedShellEnv() ?? {};
|
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>();
|
const seen = new Set<string>();
|
||||||
for (const entry of pathEntries) {
|
for (const entry of pathEntries) {
|
||||||
const normalizedEntry = path.resolve(entry);
|
const normalizedEntry = path.resolve(entry);
|
||||||
|
|
@ -169,6 +178,21 @@ function resolvePathCodexBinary(): string | null {
|
||||||
return 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(
|
export function getCodexRuntimePlatformCandidates(
|
||||||
platform: NodeJS.Platform = process.platform,
|
platform: NodeJS.Platform = process.platform,
|
||||||
arch: string = process.arch
|
arch: string = process.arch
|
||||||
|
|
@ -543,7 +567,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPathStatus(): Promise<CodexRuntimeStatus> {
|
private async getPathStatus(): Promise<CodexRuntimeStatus> {
|
||||||
const binaryPath = resolvePathCodexBinary();
|
const binaryPath = await resolvePathCodexBinaryWithBestEffortEnv();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
return { installed: false, source: 'missing', state: 'idle' };
|
return { installed: false, source: 'missing', state: 'idle' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import { CodexBinaryResolver } from '../services/infrastructure/codexAppServer';
|
||||||
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||||
|
|
||||||
import type { CliInstallerService } from '../services';
|
import type { CliInstallerService } from '../services';
|
||||||
|
|
@ -35,6 +36,7 @@ let service: CliInstallerService;
|
||||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||||
|
let statusCacheGeneration = 0;
|
||||||
const STATUS_CACHE_TTL_MS = 5_000;
|
const STATUS_CACHE_TTL_MS = 5_000;
|
||||||
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
|
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
|
||||||
|
|
||||||
|
|
@ -104,14 +106,19 @@ async function handleGetStatus(
|
||||||
|
|
||||||
if (!statusInFlight) {
|
if (!statusInFlight) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
statusInFlight = service
|
const generation = statusCacheGeneration;
|
||||||
|
const request = service
|
||||||
.getStatus()
|
.getStatus()
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
cachedStatus = { value: status, at: Date.now() };
|
if (generation === statusCacheGeneration) {
|
||||||
|
cachedStatus = { value: status, at: Date.now() };
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
cachedStatus = null;
|
if (generation === statusCacheGeneration) {
|
||||||
|
cachedStatus = null;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -119,8 +126,11 @@ async function handleGetStatus(
|
||||||
if (ms >= 2000) {
|
if (ms >= 2000) {
|
||||||
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
||||||
}
|
}
|
||||||
statusInFlight = null;
|
if (statusInFlight === request) {
|
||||||
|
statusInFlight = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
statusInFlight = request;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await statusInFlight;
|
const status = await statusInFlight;
|
||||||
|
|
@ -182,14 +192,19 @@ async function handleGetProviderStatus(
|
||||||
return { success: true, data: status };
|
return { success: true, data: status };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generation = statusCacheGeneration;
|
||||||
const request = service
|
const request = service
|
||||||
.getProviderStatus(providerId)
|
.getProviderStatus(providerId)
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
patchCachedProviderStatus(status);
|
if (generation === statusCacheGeneration) {
|
||||||
|
patchCachedProviderStatus(status);
|
||||||
|
}
|
||||||
return status;
|
return status;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
providerStatusInFlight.delete(providerId);
|
if (providerStatusInFlight.get(providerId) === request) {
|
||||||
|
providerStatusInFlight.delete(providerId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
providerStatusInFlight.set(providerId, request);
|
providerStatusInFlight.set(providerId, request);
|
||||||
|
|
@ -229,9 +244,12 @@ async function handleVerifyProviderModels(
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||||
|
statusCacheGeneration += 1;
|
||||||
cachedStatus = null;
|
cachedStatus = null;
|
||||||
|
statusInFlight = null;
|
||||||
providerStatusInFlight.clear();
|
providerStatusInFlight.clear();
|
||||||
ClaudeBinaryResolver.clearCache();
|
ClaudeBinaryResolver.clearCache();
|
||||||
|
CodexBinaryResolver.clearCache();
|
||||||
service.invalidateStatusCache();
|
service.invalidateStatusCache();
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,21 +134,25 @@ export class CodexBinaryResolver {
|
||||||
cacheVerifiedAt = Date.now();
|
cacheVerifiedAt = Date.now();
|
||||||
return verifiedAppManagedBinaryPath;
|
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) {
|
const verified = await verifyBinary(cachedBinaryPath);
|
||||||
return cachedBinaryPath;
|
if (verified) {
|
||||||
}
|
cacheVerifiedAt = Date.now();
|
||||||
|
return verified;
|
||||||
|
}
|
||||||
|
|
||||||
const verified = await verifyBinary(cachedBinaryPath);
|
cachedBinaryPath = undefined;
|
||||||
if (verified) {
|
cacheVerifiedAt = 0;
|
||||||
cacheVerifiedAt = Date.now();
|
|
||||||
return verified;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedBinaryPath = undefined;
|
|
||||||
cacheVerifiedAt = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolveInFlight) {
|
if (!resolveInFlight) {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ describe('CodexBinaryResolver', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
setPlatform(originalPlatform);
|
setPlatform(originalPlatform);
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
process.env.PATHEXT = originalPathExt;
|
process.env.PATHEXT = originalPathExt;
|
||||||
|
|
@ -175,6 +176,35 @@ describe('CodexBinaryResolver', () => {
|
||||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(appManagedBinary);
|
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 () => {
|
it('skips Windows PATH candidates that exist but cannot be launched', async () => {
|
||||||
const blockedDir =
|
const blockedDir =
|
||||||
'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources';
|
'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources';
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ import {
|
||||||
getProviderCredentialSummary,
|
getProviderCredentialSummary,
|
||||||
getProviderCurrentRuntimeSummary,
|
getProviderCurrentRuntimeSummary,
|
||||||
getProviderDisconnectAction,
|
getProviderDisconnectAction,
|
||||||
isOpenCodeCatalogHydrating,
|
|
||||||
isConnectionManagedRuntimeProvider,
|
isConnectionManagedRuntimeProvider,
|
||||||
|
isOpenCodeCatalogHydrating,
|
||||||
shouldShowProviderConnectAction,
|
shouldShowProviderConnectAction,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} from '@renderer/components/runtime/providerConnectionUi';
|
||||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
|
|
@ -626,7 +626,6 @@ function shouldShowCodexInstallAction(
|
||||||
!showSkeleton &&
|
!showSkeleton &&
|
||||||
!provider.authenticated &&
|
!provider.authenticated &&
|
||||||
runtimeMissing &&
|
runtimeMissing &&
|
||||||
codexRuntimeStatus?.source !== 'path' &&
|
|
||||||
!(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed')
|
!(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1356,12 +1355,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
}, [installCli]);
|
}, [installCli]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refreshCliStatusForCurrentMode({
|
void (async () => {
|
||||||
multimodelEnabled,
|
await invalidateCliStatus();
|
||||||
bootstrapCliStatus,
|
await refreshCliStatusForCurrentMode({
|
||||||
fetchCliStatus,
|
multimodelEnabled,
|
||||||
});
|
bootstrapCliStatus,
|
||||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
fetchCliStatus,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
||||||
|
|
||||||
const handleToggleProvidersCollapsed = useCallback(() => {
|
const handleToggleProvidersCollapsed = useCallback(() => {
|
||||||
setProvidersCollapsed((current) => {
|
setProvidersCollapsed((current) => {
|
||||||
|
|
@ -1438,9 +1440,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const handleProviderRefresh = useCallback(
|
const handleProviderRefresh = useCallback(
|
||||||
(providerId: CliProviderId) => {
|
(providerId: CliProviderId) => {
|
||||||
void fetchCliProviderStatus(providerId);
|
void (async () => {
|
||||||
|
await invalidateCliStatus();
|
||||||
|
await fetchCliProviderStatus(providerId);
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
[fetchCliProviderStatus]
|
[fetchCliProviderStatus, invalidateCliStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleProviderBackendChange = useCallback(
|
const handleProviderBackendChange = useCallback(
|
||||||
|
|
@ -1524,8 +1529,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
}
|
}
|
||||||
providerStatusLoading={cliProviderStatusLoading}
|
providerStatusLoading={cliProviderStatusLoading}
|
||||||
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
|
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
|
||||||
|
codexRuntimeStatus={codexRuntimeStatus}
|
||||||
|
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||||
|
onInstallCodexRuntime={() => installCodexRuntime()}
|
||||||
onSelectBackend={handleProviderBackendChange}
|
onSelectBackend={handleProviderBackendChange}
|
||||||
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
|
onRefreshProvider={handleProviderRefresh}
|
||||||
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
|
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
|
||||||
/>
|
/>
|
||||||
{providerTerminal && renderCliStatus.binaryPath && (
|
{providerTerminal && renderCliStatus.binaryPath && (
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { useStore } from '@renderer/store';
|
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 {
|
import {
|
||||||
formatProviderAuthMethodLabelForProvider,
|
formatProviderAuthMethodLabelForProvider,
|
||||||
|
|
@ -60,6 +60,7 @@ import {
|
||||||
ProviderRuntimeBackendSelector,
|
ProviderRuntimeBackendSelector,
|
||||||
} from './ProviderRuntimeBackendSelector';
|
} from './ProviderRuntimeBackendSelector';
|
||||||
|
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types';
|
import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -80,6 +81,9 @@ interface Props {
|
||||||
readonly projectPath?: string | null;
|
readonly projectPath?: string | null;
|
||||||
readonly providerStatusLoading?: Partial<Record<CliProviderId, boolean>>;
|
readonly providerStatusLoading?: Partial<Record<CliProviderId, boolean>>;
|
||||||
readonly disabled?: 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 onSelectBackend: (providerId: CliProviderId, backendId: string) => Promise<void> | void;
|
||||||
readonly onRefreshProvider?: (providerId: CliProviderId) => Promise<void> | void;
|
readonly onRefreshProvider?: (providerId: CliProviderId) => Promise<void> | void;
|
||||||
readonly onRequestLogin?: (providerId: CliProviderId) => void;
|
readonly onRequestLogin?: (providerId: CliProviderId) => void;
|
||||||
|
|
@ -125,6 +129,33 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv
|
||||||
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
|
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 {
|
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
|
||||||
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
|
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
|
||||||
return matches.find((entry) => entry.scope === 'user') ?? null;
|
return matches.find((entry) => entry.scope === 'user') ?? null;
|
||||||
|
|
@ -565,6 +596,9 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
projectPath = null,
|
projectPath = null,
|
||||||
providerStatusLoading = {},
|
providerStatusLoading = {},
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
codexRuntimeStatus = null,
|
||||||
|
codexRuntimeStatusLoading = false,
|
||||||
|
onInstallCodexRuntime,
|
||||||
onSelectBackend,
|
onSelectBackend,
|
||||||
onRefreshProvider,
|
onRefreshProvider,
|
||||||
onRequestLogin,
|
onRequestLogin,
|
||||||
|
|
@ -765,6 +799,15 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
const connectionBusy = disabled || connectionLoading;
|
const connectionBusy = disabled || connectionLoading;
|
||||||
const codexActionBusy =
|
const codexActionBusy =
|
||||||
disabled || selectedProviderLoading || connectionSaving || codexAccount.loading;
|
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 runtimeBusy = disabled || selectedProviderLoading || runtimeSaving;
|
||||||
const anthropicFastModeCapability =
|
const anthropicFastModeCapability =
|
||||||
selectedProvider?.providerId === 'anthropic'
|
selectedProvider?.providerId === 'anthropic'
|
||||||
|
|
@ -1397,6 +1440,26 @@ export const ProviderRuntimeSettingsDialog = ({
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</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 ? (
|
{codexLoginPending ? (
|
||||||
<>
|
<>
|
||||||
<CodexLoginLinkCopyButton
|
<CodexLoginLinkCopyButton
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import {
|
||||||
getProviderCredentialSummary,
|
getProviderCredentialSummary,
|
||||||
getProviderCurrentRuntimeSummary,
|
getProviderCurrentRuntimeSummary,
|
||||||
getProviderDisconnectAction,
|
getProviderDisconnectAction,
|
||||||
isOpenCodeCatalogHydrating,
|
|
||||||
isConnectionManagedRuntimeProvider,
|
isConnectionManagedRuntimeProvider,
|
||||||
|
isOpenCodeCatalogHydrating,
|
||||||
shouldShowProviderConnectAction,
|
shouldShowProviderConnectAction,
|
||||||
} from '@renderer/components/runtime/providerConnectionUi';
|
} from '@renderer/components/runtime/providerConnectionUi';
|
||||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
|
|
@ -201,9 +201,12 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
downloadTotal,
|
downloadTotal,
|
||||||
installerError,
|
installerError,
|
||||||
completedVersion,
|
completedVersion,
|
||||||
|
codexRuntimeStatus,
|
||||||
|
codexRuntimeStatusLoading,
|
||||||
bootstrapCliStatus,
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
fetchCliProviderStatus,
|
fetchCliProviderStatus,
|
||||||
|
installCodexRuntime,
|
||||||
installCli,
|
installCli,
|
||||||
isBusy,
|
isBusy,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
|
@ -286,12 +289,25 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
}, [installCli]);
|
}, [installCli]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refreshCliStatusForCurrentMode({
|
void (async () => {
|
||||||
multimodelEnabled,
|
await invalidateCliStatus();
|
||||||
bootstrapCliStatus,
|
await refreshCliStatusForCurrentMode({
|
||||||
fetchCliStatus,
|
multimodelEnabled,
|
||||||
});
|
bootstrapCliStatus,
|
||||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
fetchCliStatus,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
||||||
|
|
||||||
|
const handleProviderRefresh = useCallback(
|
||||||
|
(providerId: CliProviderId) => {
|
||||||
|
void (async () => {
|
||||||
|
await invalidateCliStatus();
|
||||||
|
await fetchCliProviderStatus(providerId);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[fetchCliProviderStatus, invalidateCliStatus]
|
||||||
|
);
|
||||||
|
|
||||||
const handleProviderLogout = useCallback(
|
const handleProviderLogout = useCallback(
|
||||||
async (providerId: CliProviderId) => {
|
async (providerId: CliProviderId) => {
|
||||||
|
|
@ -673,8 +689,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
initialProviderId={manageProviderId}
|
initialProviderId={manageProviderId}
|
||||||
providerStatusLoading={cliProviderStatusLoading}
|
providerStatusLoading={cliProviderStatusLoading}
|
||||||
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
|
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
|
||||||
|
codexRuntimeStatus={codexRuntimeStatus}
|
||||||
|
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||||
|
onInstallCodexRuntime={() => installCodexRuntime()}
|
||||||
onSelectBackend={handleRuntimeBackendChange}
|
onSelectBackend={handleRuntimeBackendChange}
|
||||||
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
|
onRefreshProvider={handleProviderRefresh}
|
||||||
onRequestLogin={(providerId) =>
|
onRequestLogin={(providerId) =>
|
||||||
setProviderTerminal({ providerId, action: 'login' })
|
setProviderTerminal({ providerId, action: 'login' })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ interface AddMemberDialogProps {
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIALOG_WIDTH = 'w-[720px]';
|
const DIALOG_WIDTH = 'max-w-[52rem]';
|
||||||
|
|
||||||
function deriveExistingWorktreeDefault(
|
function deriveExistingWorktreeDefault(
|
||||||
existingMembers: AddMemberDialogProps['existingMembers']
|
existingMembers: AddMemberDialogProps['existingMembers']
|
||||||
|
|
@ -179,7 +179,7 @@ export const AddMemberDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className={`${DIALOG_WIDTH} max-w-[90vw]`}>
|
<DialogContent className={DIALOG_WIDTH}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Members</DialogTitle>
|
<DialogTitle>Add Members</DialogTitle>
|
||||||
<DialogDescription>Add new members to {teamName}</DialogDescription>
|
<DialogDescription>Add new members to {teamName}</DialogDescription>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,30 @@
|
||||||
import { createHash } from 'crypto';
|
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 os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { gzipSync } from 'zlib';
|
import { gzipSync } from 'zlib';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const execCliMock = vi.hoisted(() => vi.fn());
|
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', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
execCli: execCliMock,
|
execCli: execCliMock,
|
||||||
}));
|
}));
|
||||||
|
vi.mock('@main/utils/cliPathMerge', () => ({
|
||||||
|
buildMergedCliPath: buildMergedCliPathMock,
|
||||||
|
}));
|
||||||
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
|
getCachedShellEnv: getCachedShellEnvMock,
|
||||||
|
resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock,
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createCodexRuntimeInstallerFeature,
|
||||||
extractCodexRuntimePackageFilesFromTarball,
|
extractCodexRuntimePackageFilesFromTarball,
|
||||||
getCodexRuntimePlatformCandidates,
|
getCodexRuntimePlatformCandidates,
|
||||||
resolveAppManagedCodexRuntimeBinaryPath,
|
resolveAppManagedCodexRuntimeBinaryPath,
|
||||||
|
|
@ -21,6 +34,7 @@ import {
|
||||||
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
let tempRoot: string | null = null;
|
let tempRoot: string | null = null;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
|
||||||
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
|
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
|
||||||
const encoded = value
|
const encoded = value
|
||||||
|
|
@ -65,10 +79,17 @@ describe('CodexRuntimeInstallerService resolver', () => {
|
||||||
setAppDataBasePath(tempRoot);
|
setAppDataBasePath(tempRoot);
|
||||||
execCliMock.mockReset();
|
execCliMock.mockReset();
|
||||||
execCliMock.mockResolvedValue({ stdout: 'codex-cli 1.0.0\n', stderr: '' });
|
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 () => {
|
afterEach(async () => {
|
||||||
setAppDataBasePath(null);
|
setAppDataBasePath(null);
|
||||||
|
process.env.PATH = originalPath;
|
||||||
if (tempRoot) {
|
if (tempRoot) {
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
tempRoot = null;
|
tempRoot = null;
|
||||||
|
|
@ -168,6 +189,38 @@ describe('CodexRuntimeInstallerService resolver', () => {
|
||||||
|
|
||||||
await expect(resolveVerifiedAppManagedCodexRuntimeBinaryPath()).resolves.toBeNull();
|
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', () => {
|
describe('CodexRuntimeInstallerService package safety helpers', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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', () => ({
|
vi.mock('@shared/utils/logger', () => ({
|
||||||
createLogger: () => ({
|
createLogger: () => ({
|
||||||
info: vi.fn(),
|
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 {
|
import {
|
||||||
initializeCliInstallerHandlers,
|
initializeCliInstallerHandlers,
|
||||||
registerCliInstallerHandlers,
|
registerCliInstallerHandlers,
|
||||||
|
|
@ -16,6 +31,7 @@ import {
|
||||||
import {
|
import {
|
||||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||||
CLI_INSTALLER_GET_STATUS,
|
CLI_INSTALLER_GET_STATUS,
|
||||||
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
|
|
@ -25,6 +41,20 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown;
|
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 & {
|
function createMockIpcMain(): IpcMain & {
|
||||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
||||||
} {
|
} {
|
||||||
|
|
@ -84,6 +114,7 @@ function provider(overrides: Partial<CliProviderStatus> & { providerId: CliProvi
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(providers: CliProviderStatus[]): CliInstallationStatus {
|
function status(providers: CliProviderStatus[]): CliInstallationStatus {
|
||||||
|
const authenticatedProvider = providers.find((entry) => entry.authenticated) ?? null;
|
||||||
return {
|
return {
|
||||||
flavor: 'agent_teams_orchestrator',
|
flavor: 'agent_teams_orchestrator',
|
||||||
displayName: 'Multimodel runtime',
|
displayName: 'Multimodel runtime',
|
||||||
|
|
@ -96,9 +127,9 @@ function status(providers: CliProviderStatus[]): CliInstallationStatus {
|
||||||
launchError: null,
|
launchError: null,
|
||||||
latestVersion: null,
|
latestVersion: null,
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
authLoggedIn: false,
|
authLoggedIn: authenticatedProvider !== null,
|
||||||
authStatusChecking: false,
|
authStatusChecking: false,
|
||||||
authMethod: null,
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
providers,
|
providers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +145,7 @@ describe('cliInstaller IPC handlers', () => {
|
||||||
invalidateStatusCache: ReturnType<typeof vi.fn>;
|
invalidateStatusCache: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
ipcMain = createMockIpcMain();
|
ipcMain = createMockIpcMain();
|
||||||
service = {
|
service = {
|
||||||
|
|
@ -127,6 +158,8 @@ describe('cliInstaller IPC handlers', () => {
|
||||||
};
|
};
|
||||||
initializeCliInstallerHandlers(service as unknown as CliInstallerService);
|
initializeCliInstallerHandlers(service as unknown as CliInstallerService);
|
||||||
registerCliInstallerHandlers(ipcMain);
|
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 () => {
|
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?.authLoggedIn).toBe(false);
|
||||||
expect(cached.data?.authMethod).toBeNull();
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ interface StoreState {
|
||||||
openCodeRuntimeStatus: Record<string, unknown> | null;
|
openCodeRuntimeStatus: Record<string, unknown> | null;
|
||||||
openCodeRuntimeStatusLoading: boolean;
|
openCodeRuntimeStatusLoading: boolean;
|
||||||
openCodeRuntimeError: string | null;
|
openCodeRuntimeError: string | null;
|
||||||
|
codexRuntimeStatus: Record<string, unknown> | null;
|
||||||
|
codexRuntimeStatusLoading: boolean;
|
||||||
|
codexRuntimeError: string | null;
|
||||||
bootstrapCliStatus: ReturnType<typeof vi.fn>;
|
bootstrapCliStatus: ReturnType<typeof vi.fn>;
|
||||||
fetchCliStatus: ReturnType<typeof vi.fn>;
|
fetchCliStatus: ReturnType<typeof vi.fn>;
|
||||||
fetchCliProviderStatus: ReturnType<typeof vi.fn>;
|
fetchCliProviderStatus: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -35,6 +38,9 @@ interface StoreState {
|
||||||
fetchOpenCodeRuntimeStatus: ReturnType<typeof vi.fn>;
|
fetchOpenCodeRuntimeStatus: ReturnType<typeof vi.fn>;
|
||||||
installOpenCodeRuntime: ReturnType<typeof vi.fn>;
|
installOpenCodeRuntime: ReturnType<typeof vi.fn>;
|
||||||
invalidateOpenCodeRuntimeStatus: 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: {
|
appConfig: {
|
||||||
general: {
|
general: {
|
||||||
multimodelEnabled: boolean;
|
multimodelEnabled: boolean;
|
||||||
|
|
@ -328,6 +334,9 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
storeState.openCodeRuntimeStatus = null;
|
storeState.openCodeRuntimeStatus = null;
|
||||||
storeState.openCodeRuntimeStatusLoading = false;
|
storeState.openCodeRuntimeStatusLoading = false;
|
||||||
storeState.openCodeRuntimeError = null;
|
storeState.openCodeRuntimeError = null;
|
||||||
|
storeState.codexRuntimeStatus = null;
|
||||||
|
storeState.codexRuntimeStatusLoading = false;
|
||||||
|
storeState.codexRuntimeError = null;
|
||||||
storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
|
storeState.bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||||
storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
|
storeState.fetchCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||||
storeState.fetchCliProviderStatus = 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.fetchOpenCodeRuntimeStatus = vi.fn().mockResolvedValue(undefined);
|
||||||
storeState.installOpenCodeRuntime = vi.fn().mockResolvedValue(undefined);
|
storeState.installOpenCodeRuntime = vi.fn().mockResolvedValue(undefined);
|
||||||
storeState.invalidateOpenCodeRuntimeStatus = 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 = {
|
storeState.appConfig = {
|
||||||
general: {
|
general: {
|
||||||
multimodelEnabled: true,
|
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 () => {
|
it('shows OpenCode app-managed install progress on the dashboard provider card', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliInstallerState = 'idle';
|
storeState.cliInstallerState = 'idle';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue