Skip to content

Commit 67fbd1e

Browse files
committed
fix(codex): improve runtime CLI discovery
1 parent 8db61d4 commit 67fbd1e

11 files changed

Lines changed: 498 additions & 48 deletions

File tree

src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
22
import { execCli } from '@main/utils/childProcess';
3+
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
34
import { getAppDataPath } from '@main/utils/pathDecoder';
45
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
5-
import { getCachedShellEnv } from '@main/utils/shellEnv';
6+
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
67
import { getErrorMessage } from '@shared/utils/errorHandling';
78
import { createLogger } from '@shared/utils/logger';
89
import { createHash, randomUUID } from 'crypto';
@@ -27,6 +28,7 @@ const MAX_TARBALL_BYTES = 160 * 1024 * 1024;
2728
const MAX_UNPACKED_BYTES = 650 * 1024 * 1024;
2829
const FETCH_TIMEOUT_MS = 60_000;
2930
const VERSION_TIMEOUT_MS = 10_000;
31+
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
3032

3133
interface NpmPackageMetadata {
3234
name?: string;
@@ -149,9 +151,16 @@ function splitPathEnv(pathValue: string | undefined): string[] {
149151
.filter(Boolean);
150152
}
151153

152-
function resolvePathCodexBinary(): string | null {
154+
function resolvePathCodexBinary(
155+
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
156+
): string | null {
153157
const shellEnv = getCachedShellEnv() ?? {};
154-
const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)];
158+
const pathEntries = [
159+
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
160+
...splitPathEnv(shellEnv.PATH),
161+
...splitPathEnv(buildMergedCliPath(null)),
162+
...splitPathEnv(process.env.PATH),
163+
];
155164
const seen = new Set<string>();
156165
for (const entry of pathEntries) {
157166
const normalizedEntry = path.resolve(entry);
@@ -169,6 +178,21 @@ function resolvePathCodexBinary(): string | null {
169178
return null;
170179
}
171180

181+
async function resolvePathCodexBinaryWithBestEffortEnv(
182+
options: { shellEnvTimeoutMs?: number } = {}
183+
): Promise<string | null> {
184+
const cachedCandidate = resolvePathCodexBinary();
185+
if (cachedCandidate) {
186+
return cachedCandidate;
187+
}
188+
189+
const shellEnv = await resolveInteractiveShellEnvBestEffort({
190+
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
191+
fallbackEnv: process.env,
192+
});
193+
return resolvePathCodexBinary([shellEnv]);
194+
}
195+
172196
export function getCodexRuntimePlatformCandidates(
173197
platform: NodeJS.Platform = process.platform,
174198
arch: string = process.arch
@@ -543,7 +567,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
543567
}
544568

545569
private async getPathStatus(): Promise<CodexRuntimeStatus> {
546-
const binaryPath = resolvePathCodexBinary();
570+
const binaryPath = await resolvePathCodexBinaryWithBestEffortEnv();
547571
if (!binaryPath) {
548572
return { installed: false, source: 'missing', state: 'idle' };
549573
}

src/main/ipc/cliInstaller.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { getErrorMessage } from '@shared/utils/errorHandling';
1919
import { createLogger } from '@shared/utils/logger';
2020

21+
import { CodexBinaryResolver } from '../services/infrastructure/codexAppServer';
2122
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
2223

2324
import type { CliInstallerService } from '../services';
@@ -35,6 +36,7 @@ let service: CliInstallerService;
3536
let statusInFlight: Promise<CliInstallationStatus> | null = null;
3637
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
3738
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
39+
let statusCacheGeneration = 0;
3840
const STATUS_CACHE_TTL_MS = 5_000;
3941
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
4042

@@ -104,23 +106,31 @@ async function handleGetStatus(
104106

105107
if (!statusInFlight) {
106108
const startedAt = Date.now();
107-
statusInFlight = service
109+
const generation = statusCacheGeneration;
110+
const request = service
108111
.getStatus()
109112
.then((status) => {
110-
cachedStatus = { value: status, at: Date.now() };
113+
if (generation === statusCacheGeneration) {
114+
cachedStatus = { value: status, at: Date.now() };
115+
}
111116
return status;
112117
})
113118
.catch((err) => {
114-
cachedStatus = null;
119+
if (generation === statusCacheGeneration) {
120+
cachedStatus = null;
121+
}
115122
throw err;
116123
})
117124
.finally(() => {
118125
const ms = Date.now() - startedAt;
119126
if (ms >= 2000) {
120127
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
121128
}
122-
statusInFlight = null;
129+
if (statusInFlight === request) {
130+
statusInFlight = null;
131+
}
123132
});
133+
statusInFlight = request;
124134
}
125135

126136
const status = await statusInFlight;
@@ -182,14 +192,19 @@ async function handleGetProviderStatus(
182192
return { success: true, data: status };
183193
}
184194

195+
const generation = statusCacheGeneration;
185196
const request = service
186197
.getProviderStatus(providerId)
187198
.then((status) => {
188-
patchCachedProviderStatus(status);
199+
if (generation === statusCacheGeneration) {
200+
patchCachedProviderStatus(status);
201+
}
189202
return status;
190203
})
191204
.finally(() => {
192-
providerStatusInFlight.delete(providerId);
205+
if (providerStatusInFlight.get(providerId) === request) {
206+
providerStatusInFlight.delete(providerId);
207+
}
193208
});
194209

195210
providerStatusInFlight.set(providerId, request);
@@ -229,9 +244,12 @@ async function handleVerifyProviderModels(
229244
}
230245

231246
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
247+
statusCacheGeneration += 1;
232248
cachedStatus = null;
249+
statusInFlight = null;
233250
providerStatusInFlight.clear();
234251
ClaudeBinaryResolver.clearCache();
252+
CodexBinaryResolver.clearCache();
235253
service.invalidateStatusCache();
236254
return { success: true, data: undefined };
237255
}

src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -134,21 +134,25 @@ export class CodexBinaryResolver {
134134
cacheVerifiedAt = Date.now();
135135
return verifiedAppManagedBinaryPath;
136136
}
137-
return null;
138-
}
137+
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
138+
return null;
139+
}
140+
cachedBinaryPath = undefined;
141+
cacheVerifiedAt = 0;
142+
} else {
143+
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
144+
return cachedBinaryPath;
145+
}
139146

140-
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
141-
return cachedBinaryPath;
142-
}
147+
const verified = await verifyBinary(cachedBinaryPath);
148+
if (verified) {
149+
cacheVerifiedAt = Date.now();
150+
return verified;
151+
}
143152

144-
const verified = await verifyBinary(cachedBinaryPath);
145-
if (verified) {
146-
cacheVerifiedAt = Date.now();
147-
return verified;
153+
cachedBinaryPath = undefined;
154+
cacheVerifiedAt = 0;
148155
}
149-
150-
cachedBinaryPath = undefined;
151-
cacheVerifiedAt = 0;
152156
}
153157

154158
if (!resolveInFlight) {

src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe('CodexBinaryResolver', () => {
9090
});
9191

9292
afterEach(() => {
93+
vi.useRealTimers();
9394
setPlatform(originalPlatform);
9495
process.env.PATH = originalPath;
9596
process.env.PATHEXT = originalPathExt;
@@ -175,6 +176,35 @@ describe('CodexBinaryResolver', () => {
175176
await expect(CodexBinaryResolver.resolve()).resolves.toBe(appManagedBinary);
176177
});
177178

179+
it('recovers a negative cache entry from PATH after the miss cache expires', async () => {
180+
vi.useFakeTimers();
181+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
182+
setPlatform('darwin');
183+
process.env.PATH = '/usr/local/bin';
184+
const codexShim = path.posix.join('/usr/local/bin', 'codex');
185+
buildMergedCliPathMock.mockReturnValue('/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin');
186+
187+
accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
188+
189+
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
190+
CodexBinaryResolver.clearCache();
191+
192+
await expect(CodexBinaryResolver.resolve()).resolves.toBeNull();
193+
194+
accessMock.mockImplementation((filePath) => {
195+
if (filePath === codexShim) {
196+
return Promise.resolve();
197+
}
198+
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
199+
});
200+
201+
await expect(CodexBinaryResolver.resolve()).resolves.toBeNull();
202+
203+
vi.advanceTimersByTime(30_001);
204+
205+
await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim);
206+
});
207+
178208
it('skips Windows PATH candidates that exist but cannot be launched', async () => {
179209
const blockedDir =
180210
'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources';

src/renderer/components/dashboard/CliStatusBanner.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import {
2727
getProviderCredentialSummary,
2828
getProviderCurrentRuntimeSummary,
2929
getProviderDisconnectAction,
30-
isOpenCodeCatalogHydrating,
3130
isConnectionManagedRuntimeProvider,
31+
isOpenCodeCatalogHydrating,
3232
shouldShowProviderConnectAction,
3333
} from '@renderer/components/runtime/providerConnectionUi';
3434
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
@@ -626,7 +626,6 @@ function shouldShowCodexInstallAction(
626626
!showSkeleton &&
627627
!provider.authenticated &&
628628
runtimeMissing &&
629-
codexRuntimeStatus?.source !== 'path' &&
630629
!(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed')
631630
);
632631
}
@@ -1356,12 +1355,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
13561355
}, [installCli]);
13571356

13581357
const handleRefresh = useCallback(() => {
1359-
void refreshCliStatusForCurrentMode({
1360-
multimodelEnabled,
1361-
bootstrapCliStatus,
1362-
fetchCliStatus,
1363-
});
1364-
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
1358+
void (async () => {
1359+
await invalidateCliStatus();
1360+
await refreshCliStatusForCurrentMode({
1361+
multimodelEnabled,
1362+
bootstrapCliStatus,
1363+
fetchCliStatus,
1364+
});
1365+
})();
1366+
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
13651367

13661368
const handleToggleProvidersCollapsed = useCallback(() => {
13671369
setProvidersCollapsed((current) => {
@@ -1438,9 +1440,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
14381440

14391441
const handleProviderRefresh = useCallback(
14401442
(providerId: CliProviderId) => {
1441-
void fetchCliProviderStatus(providerId);
1443+
void (async () => {
1444+
await invalidateCliStatus();
1445+
await fetchCliProviderStatus(providerId);
1446+
})();
14421447
},
1443-
[fetchCliProviderStatus]
1448+
[fetchCliProviderStatus, invalidateCliStatus]
14441449
);
14451450

14461451
const handleProviderBackendChange = useCallback(
@@ -1524,8 +1529,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
15241529
}
15251530
providerStatusLoading={cliProviderStatusLoading}
15261531
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
1532+
codexRuntimeStatus={codexRuntimeStatus}
1533+
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
1534+
onInstallCodexRuntime={() => installCodexRuntime()}
15271535
onSelectBackend={handleProviderBackendChange}
1528-
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
1536+
onRefreshProvider={handleProviderRefresh}
15291537
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
15301538
/>
15311539
{providerTerminal && renderCliStatus.binaryPath && (

0 commit comments

Comments
 (0)