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 { 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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue