feat: add managed codex runtime installer
This commit is contained in:
parent
6e67e9b3a4
commit
a474076330
34 changed files with 2013 additions and 21 deletions
66
.github/workflows/codex-runtime-smoke.yml
vendored
Normal file
66
.github/workflows/codex-runtime-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
name: Codex Runtime Smoke
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/codex-runtime-smoke.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'scripts/smoke/codex-runtime-install.ts'
|
||||||
|
- 'src/features/codex-runtime-installer/**'
|
||||||
|
- 'src/main/services/infrastructure/codexAppServer/**'
|
||||||
|
- 'src/main/services/runtime/providerAwareCliEnv.ts'
|
||||||
|
- 'src/main/utils/childProcess.ts'
|
||||||
|
- 'src/main/utils/pathDecoder.ts'
|
||||||
|
- 'tsconfig*.json'
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/codex-runtime-smoke.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'scripts/smoke/codex-runtime-install.ts'
|
||||||
|
- 'src/features/codex-runtime-installer/**'
|
||||||
|
- 'src/main/services/infrastructure/codexAppServer/**'
|
||||||
|
- 'src/main/services/runtime/providerAwareCliEnv.ts'
|
||||||
|
- 'src/main/utils/childProcess.ts'
|
||||||
|
- 'src/main/utils/pathDecoder.ts'
|
||||||
|
- 'tsconfig*.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
install:
|
||||||
|
name: Install Codex runtime (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Enable Windows long paths
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: git config --global core.longpaths true
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --no-frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
- name: Smoke Codex app-managed runtime install
|
||||||
|
run: pnpm smoke:codex-runtime-install
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs",
|
"team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs",
|
||||||
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
||||||
"team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts",
|
"team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts",
|
||||||
|
"smoke:codex-runtime-install": "tsx scripts/smoke/codex-runtime-install.ts",
|
||||||
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||||
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
|
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
|
||||||
"dist": "electron-builder --mac --win --linux",
|
"dist": "electron-builder --mac --win --linux",
|
||||||
|
|
|
||||||
170
scripts/smoke/codex-runtime-install.ts
Normal file
170
scripts/smoke/codex-runtime-install.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CodexRuntimeInstallerService,
|
||||||
|
resolveAppManagedCodexRuntimeBinaryPath,
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPath,
|
||||||
|
} from '@features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService';
|
||||||
|
import { CodexBinaryResolver } from '@main/services/infrastructure/codexAppServer/CodexBinaryResolver';
|
||||||
|
import { getAppDataPath, setAppDataBasePath } from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const VERSION_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
interface CodexRuntimeSmokeManifest {
|
||||||
|
rootVersion?: string;
|
||||||
|
platformVersion?: string;
|
||||||
|
platformTarget?: string;
|
||||||
|
binaryPath?: string;
|
||||||
|
integrity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexRuntimeSmokeReport {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
arch: string;
|
||||||
|
appDataPath: string;
|
||||||
|
binaryPath: string;
|
||||||
|
statusVersion: string | null;
|
||||||
|
versionStdout: string;
|
||||||
|
resolverVersion: string | null;
|
||||||
|
rootVersion: string | null;
|
||||||
|
platformVersion: string | null;
|
||||||
|
platformTarget: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCondition(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsidePath(parentPath: string, childPath: string): boolean {
|
||||||
|
const relativePath = path.relative(parentPath, childPath);
|
||||||
|
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest(appDataPath: string): Promise<CodexRuntimeSmokeManifest> {
|
||||||
|
const manifestPath = path.join(appDataPath, 'runtimes', 'codex', 'current.json');
|
||||||
|
const raw = await readFile(manifestPath, 'utf8');
|
||||||
|
return JSON.parse(raw) as CodexRuntimeSmokeManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertExecutableVersion(binaryPath: string): Promise<string> {
|
||||||
|
const { stdout, stderr } = await execFileAsync(binaryPath, ['--version'], {
|
||||||
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
const output = `${stdout ?? ''}\n${stderr ?? ''}`.trim();
|
||||||
|
assertCondition(
|
||||||
|
/\bcodex-cli\s+\d+\.\d+\.\d+\b/i.test(output),
|
||||||
|
`Unexpected version output: ${output}`
|
||||||
|
);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSmoke(): Promise<CodexRuntimeSmokeReport> {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-runtime-smoke-'));
|
||||||
|
const keepTemp = process.env.CODEX_RUNTIME_SMOKE_KEEP_TEMP === '1';
|
||||||
|
setAppDataBasePath(tempRoot);
|
||||||
|
CodexBinaryResolver.clearCache();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = new CodexRuntimeInstallerService();
|
||||||
|
const status = await service.install();
|
||||||
|
assertCondition(status.installed, `Codex runtime install failed: ${JSON.stringify(status)}`);
|
||||||
|
assertCondition(status.binaryPath, 'Codex runtime install did not return a binary path');
|
||||||
|
assertCondition(
|
||||||
|
path.isAbsolute(status.binaryPath),
|
||||||
|
`Binary path is not absolute: ${status.binaryPath}`
|
||||||
|
);
|
||||||
|
assertCondition(existsSync(status.binaryPath), `Binary does not exist: ${status.binaryPath}`);
|
||||||
|
|
||||||
|
const binaryStat = await stat(status.binaryPath);
|
||||||
|
assertCondition(binaryStat.isFile(), `Binary path is not a file: ${status.binaryPath}`);
|
||||||
|
|
||||||
|
const appDataPath = getAppDataPath();
|
||||||
|
assertCondition(
|
||||||
|
isInsidePath(path.join(appDataPath, 'runtimes', 'codex'), status.binaryPath),
|
||||||
|
`Binary path is outside the app-managed Codex runtime root: ${status.binaryPath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifest = await readManifest(appDataPath);
|
||||||
|
assertCondition(
|
||||||
|
manifest.binaryPath === status.binaryPath,
|
||||||
|
'Manifest binary path does not match install status'
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
typeof manifest.integrity === 'string' && manifest.integrity.startsWith('sha512-'),
|
||||||
|
'Manifest integrity is missing sha512 metadata'
|
||||||
|
);
|
||||||
|
assertCondition(typeof manifest.rootVersion === 'string', 'Manifest rootVersion is missing');
|
||||||
|
assertCondition(
|
||||||
|
typeof manifest.platformVersion === 'string',
|
||||||
|
'Manifest platformVersion is missing'
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
typeof manifest.platformTarget === 'string',
|
||||||
|
'Manifest platformTarget is missing'
|
||||||
|
);
|
||||||
|
|
||||||
|
const appManagedPath = resolveAppManagedCodexRuntimeBinaryPath();
|
||||||
|
const verifiedPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||||
|
const resolvedPath = await CodexBinaryResolver.resolve();
|
||||||
|
assertCondition(
|
||||||
|
appManagedPath === status.binaryPath,
|
||||||
|
'resolveAppManagedCodexRuntimeBinaryPath mismatch'
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
verifiedPath === status.binaryPath,
|
||||||
|
'resolveVerifiedAppManagedCodexRuntimeBinaryPath mismatch'
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
resolvedPath === status.binaryPath,
|
||||||
|
'CodexBinaryResolver did not prefer the app-managed binary'
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionStdout = await assertExecutableVersion(status.binaryPath);
|
||||||
|
const resolverVersion = await CodexBinaryResolver.resolveVersion(resolvedPath);
|
||||||
|
assertCondition(
|
||||||
|
typeof resolverVersion === 'string' && /^\d+\.\d+\.\d+/.test(resolverVersion),
|
||||||
|
`CodexBinaryResolver returned an invalid version: ${resolverVersion}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
appDataPath,
|
||||||
|
binaryPath: status.binaryPath,
|
||||||
|
statusVersion: status.version ?? null,
|
||||||
|
versionStdout,
|
||||||
|
resolverVersion,
|
||||||
|
rootVersion: manifest.rootVersion,
|
||||||
|
platformVersion: manifest.platformVersion,
|
||||||
|
platformTarget: manifest.platformTarget,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
CodexBinaryResolver.clearCache();
|
||||||
|
setAppDataBasePath(null);
|
||||||
|
if (keepTemp) {
|
||||||
|
console.log(`CODEX_RUNTIME_SMOKE_KEEP_TEMP=1, keeping temp root: ${tempRoot}`);
|
||||||
|
} else {
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runSmoke()
|
||||||
|
.then((report) => {
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
8
src/features/codex-runtime-installer/contracts/api.ts
Normal file
8
src/features/codex-runtime-installer/contracts/api.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { CodexRuntimeStatus } from './dto';
|
||||||
|
|
||||||
|
export interface CodexRuntimeAPI {
|
||||||
|
getStatus: () => Promise<CodexRuntimeStatus>;
|
||||||
|
install: () => Promise<CodexRuntimeStatus>;
|
||||||
|
invalidateStatus: () => Promise<void>;
|
||||||
|
onProgress: (callback: (event: unknown, data: CodexRuntimeStatus) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export const CODEX_RUNTIME_GET_STATUS = 'codexRuntime:getStatus';
|
||||||
|
export const CODEX_RUNTIME_INSTALL = 'codexRuntime:install';
|
||||||
|
export const CODEX_RUNTIME_PROGRESS = 'codexRuntime:progress';
|
||||||
|
export const CODEX_RUNTIME_INVALIDATE_STATUS = 'codexRuntime:invalidateStatus';
|
||||||
27
src/features/codex-runtime-installer/contracts/dto.ts
Normal file
27
src/features/codex-runtime-installer/contracts/dto.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export type CodexRuntimeSource = 'app-managed' | 'path' | 'missing';
|
||||||
|
|
||||||
|
export type CodexRuntimeInstallerState =
|
||||||
|
| 'idle'
|
||||||
|
| 'checking'
|
||||||
|
| 'downloading'
|
||||||
|
| 'installing'
|
||||||
|
| 'ready'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export interface CodexRuntimeInstallProgress {
|
||||||
|
phase: CodexRuntimeInstallerState;
|
||||||
|
downloadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
percent?: number;
|
||||||
|
detail?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRuntimeStatus {
|
||||||
|
installed: boolean;
|
||||||
|
binaryPath?: string;
|
||||||
|
version?: string;
|
||||||
|
source: CodexRuntimeSource;
|
||||||
|
state: CodexRuntimeInstallerState;
|
||||||
|
progress?: CodexRuntimeInstallProgress;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
3
src/features/codex-runtime-installer/contracts/index.ts
Normal file
3
src/features/codex-runtime-installer/contracts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type * from './api';
|
||||||
|
export * from './channels';
|
||||||
|
export type * from './dto';
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
|
|
||||||
|
export interface CodexRuntimeInstallerPort {
|
||||||
|
getStatus: () => Promise<CodexRuntimeStatus>;
|
||||||
|
install: () => Promise<CodexRuntimeStatus>;
|
||||||
|
invalidateStatusCache: () => void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { CodexRuntimeInstallerPort } from '../ports/CodexRuntimeInstallerPort';
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
|
|
||||||
|
export class GetCodexRuntimeStatusUseCase {
|
||||||
|
constructor(private readonly installer: Pick<CodexRuntimeInstallerPort, 'getStatus'>) {}
|
||||||
|
|
||||||
|
execute(): Promise<CodexRuntimeStatus> {
|
||||||
|
return this.installer.getStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { CodexRuntimeInstallerPort } from '../ports/CodexRuntimeInstallerPort';
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
|
|
||||||
|
export class InstallCodexRuntimeUseCase {
|
||||||
|
constructor(private readonly installer: Pick<CodexRuntimeInstallerPort, 'install'>) {}
|
||||||
|
|
||||||
|
execute(): Promise<CodexRuntimeStatus> {
|
||||||
|
return this.installer.install();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/features/codex-runtime-installer/index.ts
Normal file
1
src/features/codex-runtime-installer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export type * from './contracts';
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import {
|
||||||
|
CODEX_RUNTIME_GET_STATUS,
|
||||||
|
CODEX_RUNTIME_INSTALL,
|
||||||
|
CODEX_RUNTIME_INVALIDATE_STATUS,
|
||||||
|
} from '@features/codex-runtime-installer/contracts';
|
||||||
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { CodexRuntimeInstallerFeatureFacade } from '../../../composition/createCodexRuntimeInstallerFeature';
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
|
import type { IpcResult } from '@shared/types';
|
||||||
|
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
|
const logger = createLogger('Feature:codex-runtime-installer:ipc');
|
||||||
|
|
||||||
|
export function registerCodexRuntimeInstallerIpc(
|
||||||
|
ipcMain: IpcMain,
|
||||||
|
feature: CodexRuntimeInstallerFeatureFacade
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CODEX_RUNTIME_GET_STATUS,
|
||||||
|
(_event: IpcMainInvokeEvent): Promise<IpcResult<CodexRuntimeStatus>> =>
|
||||||
|
withIpcResult(() => feature.getStatus())
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
CODEX_RUNTIME_INSTALL,
|
||||||
|
(_event: IpcMainInvokeEvent): Promise<IpcResult<CodexRuntimeStatus>> =>
|
||||||
|
withIpcResult(() => feature.install())
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
CODEX_RUNTIME_INVALIDATE_STATUS,
|
||||||
|
(_event: IpcMainInvokeEvent): IpcResult<void> =>
|
||||||
|
withSyncIpcResult(() => {
|
||||||
|
feature.invalidateStatus();
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
logger.info('Codex runtime installer IPC handlers registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCodexRuntimeInstallerIpc(ipcMain: IpcMain): void {
|
||||||
|
ipcMain.removeHandler(CODEX_RUNTIME_GET_STATUS);
|
||||||
|
ipcMain.removeHandler(CODEX_RUNTIME_INSTALL);
|
||||||
|
ipcMain.removeHandler(CODEX_RUNTIME_INVALIDATE_STATUS);
|
||||||
|
logger.info('Codex runtime installer IPC handlers removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withIpcResult<T>(work: () => Promise<T>): Promise<IpcResult<T>> {
|
||||||
|
try {
|
||||||
|
return { success: true, data: await work() };
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSyncIpcResult<T>(work: () => T): IpcResult<T> {
|
||||||
|
try {
|
||||||
|
return { success: true, data: work() };
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { GetCodexRuntimeStatusUseCase } from '../../core/application/use-cases/GetCodexRuntimeStatusUseCase';
|
||||||
|
import { InstallCodexRuntimeUseCase } from '../../core/application/use-cases/InstallCodexRuntimeUseCase';
|
||||||
|
import { CodexRuntimeInstallerService } from '../infrastructure/CodexRuntimeInstallerService';
|
||||||
|
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
export interface CodexRuntimeInstallerFeatureFacade {
|
||||||
|
getStatus: () => Promise<CodexRuntimeStatus>;
|
||||||
|
install: () => Promise<CodexRuntimeStatus>;
|
||||||
|
invalidateStatus: () => void;
|
||||||
|
setMainWindow: (window: BrowserWindow | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodexRuntimeInstallerFeature(): CodexRuntimeInstallerFeatureFacade {
|
||||||
|
const service = new CodexRuntimeInstallerService();
|
||||||
|
const getStatusUseCase = new GetCodexRuntimeStatusUseCase(service);
|
||||||
|
const installUseCase = new InstallCodexRuntimeUseCase(service);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getStatus: () => getStatusUseCase.execute(),
|
||||||
|
install: () => installUseCase.execute(),
|
||||||
|
invalidateStatus: () => {
|
||||||
|
service.invalidateStatusCache();
|
||||||
|
},
|
||||||
|
setMainWindow: (window) => {
|
||||||
|
service.setMainWindow(window);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/features/codex-runtime-installer/main/index.ts
Normal file
13
src/features/codex-runtime-installer/main/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export {
|
||||||
|
registerCodexRuntimeInstallerIpc,
|
||||||
|
removeCodexRuntimeInstallerIpc,
|
||||||
|
} from './adapters/input/ipc/registerCodexRuntimeInstallerIpc';
|
||||||
|
export type { CodexRuntimeInstallerFeatureFacade } from './composition/createCodexRuntimeInstallerFeature';
|
||||||
|
export { createCodexRuntimeInstallerFeature } from './composition/createCodexRuntimeInstallerFeature';
|
||||||
|
export {
|
||||||
|
extractCodexRuntimePackageFilesFromTarball,
|
||||||
|
getCodexRuntimePlatformCandidates,
|
||||||
|
resolveAppManagedCodexRuntimeBinaryPath,
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPath,
|
||||||
|
verifyCodexRuntimePackageIntegrity,
|
||||||
|
} from './infrastructure/CodexRuntimeInstallerService';
|
||||||
|
|
@ -0,0 +1,679 @@
|
||||||
|
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
|
||||||
|
import { execCli } from '@main/utils/childProcess';
|
||||||
|
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||||
|
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||||
|
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||||
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { gunzipSync } from 'zlib';
|
||||||
|
|
||||||
|
import type { CodexRuntimeInstallerPort } from '../../core/application/ports/CodexRuntimeInstallerPort';
|
||||||
|
import type {
|
||||||
|
CodexRuntimeInstallProgress,
|
||||||
|
CodexRuntimeStatus,
|
||||||
|
} from '@features/codex-runtime-installer/contracts';
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
const logger = createLogger('CodexRuntimeInstallerService');
|
||||||
|
|
||||||
|
const CHANNEL = CODEX_RUNTIME_PROGRESS;
|
||||||
|
const ROOT_PACKAGE_NAME = '@openai/codex';
|
||||||
|
const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org';
|
||||||
|
const CURRENT_MANIFEST_SCHEMA_VERSION = 1;
|
||||||
|
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;
|
||||||
|
|
||||||
|
interface NpmPackageMetadata {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
dist?: {
|
||||||
|
tarball?: string;
|
||||||
|
integrity?: string;
|
||||||
|
};
|
||||||
|
optionalDependencies?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexRuntimeManifest {
|
||||||
|
schemaVersion: 1;
|
||||||
|
rootVersion: string;
|
||||||
|
platformVersion: string;
|
||||||
|
platformTarget: string;
|
||||||
|
binaryPath: string;
|
||||||
|
integrity: string;
|
||||||
|
installedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexRuntimePlatformCandidate {
|
||||||
|
optionalDependencyName: string;
|
||||||
|
platformTag: string;
|
||||||
|
vendorTarget: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexRuntimePackageFile {
|
||||||
|
relativePath: string;
|
||||||
|
data: Buffer;
|
||||||
|
mode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuntimeRootPath(): string {
|
||||||
|
return path.join(getAppDataPath(), 'runtimes', 'codex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentManifestPath(): string {
|
||||||
|
return path.join(getRuntimeRootPath(), 'current.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
|
||||||
|
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return statSync(filePath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseManifest(value: unknown): CodexRuntimeManifest | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const manifest = value as Partial<CodexRuntimeManifest>;
|
||||||
|
if (
|
||||||
|
manifest.schemaVersion !== CURRENT_MANIFEST_SCHEMA_VERSION ||
|
||||||
|
typeof manifest.rootVersion !== 'string' ||
|
||||||
|
typeof manifest.platformVersion !== 'string' ||
|
||||||
|
typeof manifest.platformTarget !== 'string' ||
|
||||||
|
typeof manifest.binaryPath !== 'string' ||
|
||||||
|
typeof manifest.integrity !== 'string' ||
|
||||||
|
typeof manifest.installedAt !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return manifest as CodexRuntimeManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCurrentManifestSync(): CodexRuntimeManifest | null {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(getCurrentManifestPath(), 'utf8');
|
||||||
|
return parseManifest(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAppManagedCodexRuntimeBinaryPath(): string | null {
|
||||||
|
const manifest = readCurrentManifestSync();
|
||||||
|
return isAbsoluteExistingFile(manifest?.binaryPath) ? manifest.binaryPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveVerifiedAppManagedCodexRuntimeBinaryPath(): Promise<string | null> {
|
||||||
|
const binaryPath = resolveAppManagedCodexRuntimeBinaryPath();
|
||||||
|
if (!binaryPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await execCli(binaryPath, ['--version'], {
|
||||||
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
return binaryPath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutableName(): string {
|
||||||
|
return process.platform === 'win32' ? 'codex.exe' : 'codex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathExecutableNames(): string[] {
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? ['codex.exe', 'codex.cmd', 'codex.bat', 'codex']
|
||||||
|
: ['codex'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPathEnv(pathValue: string | undefined): string[] {
|
||||||
|
if (!pathValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return pathValue
|
||||||
|
.split(path.delimiter)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePathCodexBinary(): string | null {
|
||||||
|
const shellEnv = getCachedShellEnv() ?? {};
|
||||||
|
const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const entry of pathEntries) {
|
||||||
|
const normalizedEntry = path.resolve(entry);
|
||||||
|
if (seen.has(normalizedEntry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalizedEntry);
|
||||||
|
for (const executableName of getPathExecutableNames()) {
|
||||||
|
const candidate = path.join(normalizedEntry, executableName);
|
||||||
|
if (isAbsoluteExistingFile(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodexRuntimePlatformCandidates(
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
arch: string = process.arch
|
||||||
|
): CodexRuntimePlatformCandidate[] {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
if (arch === 'arm64') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
optionalDependencyName: '@openai/codex-darwin-arm64',
|
||||||
|
platformTag: 'darwin-arm64',
|
||||||
|
vendorTarget: 'aarch64-apple-darwin',
|
||||||
|
reason: 'macOS arm64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (arch === 'x64') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
optionalDependencyName: '@openai/codex-darwin-x64',
|
||||||
|
platformTag: 'darwin-x64',
|
||||||
|
vendorTarget: 'x86_64-apple-darwin',
|
||||||
|
reason: 'macOS x64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === 'linux') {
|
||||||
|
if (arch === 'arm64') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
optionalDependencyName: '@openai/codex-linux-arm64',
|
||||||
|
platformTag: 'linux-arm64',
|
||||||
|
vendorTarget: 'aarch64-unknown-linux-musl',
|
||||||
|
reason: 'Linux arm64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (arch === 'x64') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
optionalDependencyName: '@openai/codex-linux-x64',
|
||||||
|
platformTag: 'linux-x64',
|
||||||
|
vendorTarget: 'x86_64-unknown-linux-musl',
|
||||||
|
reason: 'Linux x64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === 'win32') {
|
||||||
|
if (arch === 'arm64') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
optionalDependencyName: '@openai/codex-win32-arm64',
|
||||||
|
platformTag: 'win32-arm64',
|
||||||
|
vendorTarget: 'aarch64-pc-windows-msvc',
|
||||||
|
reason: 'Windows arm64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (arch === 'x64') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
optionalDependencyName: '@openai/codex-win32-x64',
|
||||||
|
platformTag: 'win32-x64',
|
||||||
|
vendorTarget: 'x86_64-pc-windows-msvc',
|
||||||
|
reason: 'Windows x64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Codex app install is not supported on ${platform}/${arch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string): Promise<string> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} from ${url}`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPackageMetadata(
|
||||||
|
packageName: string,
|
||||||
|
version = 'latest'
|
||||||
|
): Promise<NpmPackageMetadata> {
|
||||||
|
const url = `${NPM_REGISTRY_BASE_URL}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
|
||||||
|
const raw = await fetchText(url);
|
||||||
|
const parsed = JSON.parse(raw) as NpmPackageMetadata;
|
||||||
|
if (!parsed.version || !parsed.dist?.tarball || !parsed.dist.integrity) {
|
||||||
|
throw new Error(`Invalid npm metadata for ${packageName}@${version}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyCodexRuntimePackageIntegrity(buffer: Buffer, integrity: string): void {
|
||||||
|
const match = /^sha512-([A-Za-z0-9+/=]+)$/.exec(integrity.trim());
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Codex package integrity is missing sha512 metadata');
|
||||||
|
}
|
||||||
|
const actual = createHash('sha512').update(buffer).digest('base64');
|
||||||
|
if (actual !== match[1]) {
|
||||||
|
throw new Error('Codex package integrity check failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadTarball(
|
||||||
|
url: string,
|
||||||
|
onProgress: (progress: CodexRuntimeInstallProgress) => void
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Failed to download Codex package: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const totalHeader = response.headers.get('content-length');
|
||||||
|
const totalBytes = totalHeader ? Number.parseInt(totalHeader, 10) : undefined;
|
||||||
|
if (totalBytes && totalBytes > MAX_TARBALL_BYTES) {
|
||||||
|
throw new Error('Codex package is unexpectedly large');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const chunk = Buffer.from(value);
|
||||||
|
downloadedBytes += chunk.length;
|
||||||
|
if (downloadedBytes > MAX_TARBALL_BYTES) {
|
||||||
|
throw new Error('Codex package exceeded the maximum allowed download size');
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
onProgress({
|
||||||
|
phase: 'downloading',
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
|
percent: totalBytes
|
||||||
|
? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
|
||||||
|
: undefined,
|
||||||
|
detail: totalBytes
|
||||||
|
? `Downloading Codex ${Math.round((downloadedBytes / totalBytes) * 100)}%`
|
||||||
|
: 'Downloading Codex...',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks, downloadedBytes);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTarString(buffer: Buffer, start: number, length: number): string {
|
||||||
|
const end = buffer.indexOf(0, start);
|
||||||
|
const safeEnd = end >= start && end < start + length ? end : start + length;
|
||||||
|
return buffer.toString('utf8', start, safeEnd).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTarOctal(buffer: Buffer, offset: number, length: number, label: string): number {
|
||||||
|
const raw = readTarString(buffer, offset, length).replace(/\0/g, '').trim();
|
||||||
|
const value = Number.parseInt(raw || '0', 8);
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
throw new Error(`Invalid Codex package tar entry ${label}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeTarPath(name: string): void {
|
||||||
|
if (
|
||||||
|
!name ||
|
||||||
|
name.startsWith('/') ||
|
||||||
|
name.startsWith('\\') ||
|
||||||
|
name.includes('..') ||
|
||||||
|
name.includes('\\')
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsafe Codex package tar entry: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCodexRuntimePackageFilesFromTarball(
|
||||||
|
tarball: Buffer,
|
||||||
|
vendorTarget: string,
|
||||||
|
executableName = getExecutableName()
|
||||||
|
): CodexRuntimePackageFile[] {
|
||||||
|
const tar = gunzipSync(tarball, { maxOutputLength: MAX_UNPACKED_BYTES });
|
||||||
|
const targetPrefix = `package/vendor/${vendorTarget}/`;
|
||||||
|
const targetBinaryName = `${targetPrefix}codex/${executableName}`;
|
||||||
|
const files: CodexRuntimePackageFile[] = [];
|
||||||
|
let foundBinary = false;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 512 <= tar.length) {
|
||||||
|
const name = readTarString(tar, offset, 100);
|
||||||
|
if (!name) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const prefix = readTarString(tar, offset + 345, 155);
|
||||||
|
const fullName = prefix ? `${prefix}/${name}` : name;
|
||||||
|
assertSafeTarPath(fullName);
|
||||||
|
const typeFlag = readTarString(tar, offset + 156, 1);
|
||||||
|
const mode = readTarOctal(tar, offset + 100, 8, 'mode');
|
||||||
|
const size = readTarOctal(tar, offset + 124, 12, 'size');
|
||||||
|
const dataStart = offset + 512;
|
||||||
|
const dataEnd = dataStart + size;
|
||||||
|
if (dataEnd > tar.length) {
|
||||||
|
throw new Error('Codex package tar entry exceeds archive bounds');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((typeFlag === '0' || typeFlag === '') && fullName.startsWith(targetPrefix)) {
|
||||||
|
const relativePath = fullName.slice(targetPrefix.length);
|
||||||
|
assertSafeTarPath(relativePath);
|
||||||
|
if (relativePath.length > 0) {
|
||||||
|
files.push({
|
||||||
|
relativePath,
|
||||||
|
data: Buffer.from(tar.subarray(dataStart, dataEnd)),
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
foundBinary = foundBinary || fullName === targetBinaryName;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
fullName.startsWith(targetPrefix) &&
|
||||||
|
typeFlag !== '5' &&
|
||||||
|
typeFlag !== '0' &&
|
||||||
|
typeFlag !== ''
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsupported Codex package tar entry type: ${typeFlag || 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataStart + Math.ceil(size / 512) * 512;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundBinary) {
|
||||||
|
throw new Error(`Codex package did not contain ${targetBinaryName}`);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCurrentManifest(): Promise<CodexRuntimeManifest | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(getCurrentManifestPath(), 'utf8');
|
||||||
|
return parseManifest(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlatformVersion(value: string | undefined, fallback: string): string {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const aliasMatch = /^npm:@openai\/codex@(.+)$/.exec(normalized);
|
||||||
|
if (aliasMatch?.[1]) {
|
||||||
|
return aliasMatch[1];
|
||||||
|
}
|
||||||
|
return normalized.replace(/^[~^]/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePackageFiles(
|
||||||
|
rootDir: string,
|
||||||
|
files: readonly CodexRuntimePackageFile[]
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedRoot = path.resolve(rootDir);
|
||||||
|
for (const file of files) {
|
||||||
|
const targetPath = path.resolve(normalizedRoot, file.relativePath);
|
||||||
|
if (targetPath !== normalizedRoot && !targetPath.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||||
|
throw new Error(`Unsafe Codex package output path: ${file.relativePath}`);
|
||||||
|
}
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fsp.writeFile(targetPath, file.data);
|
||||||
|
if (process.platform !== 'win32' && (file.mode & 0o111) !== 0) {
|
||||||
|
// Preserve executable bits for codex, rg, and bundled sandbox helpers.
|
||||||
|
await fsp.chmod(targetPath, file.mode & 0o777);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
|
||||||
|
private mainWindow: BrowserWindow | null = null;
|
||||||
|
private installPromise: Promise<CodexRuntimeStatus> | null = null;
|
||||||
|
private latestStatus: CodexRuntimeStatus | null = null;
|
||||||
|
|
||||||
|
setMainWindow(win: BrowserWindow | null): void {
|
||||||
|
this.mainWindow = win;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateStatusCache(): void {
|
||||||
|
this.latestStatus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(): Promise<CodexRuntimeStatus> {
|
||||||
|
if (this.installPromise && this.latestStatus) {
|
||||||
|
return this.latestStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appManagedStatus = await this.getAppManagedStatus();
|
||||||
|
if (appManagedStatus.installed) {
|
||||||
|
this.latestStatus = appManagedStatus;
|
||||||
|
return appManagedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathStatus = await this.getPathStatus();
|
||||||
|
const status =
|
||||||
|
pathStatus.installed ||
|
||||||
|
appManagedStatus.source !== 'app-managed' ||
|
||||||
|
appManagedStatus.state !== 'failed'
|
||||||
|
? pathStatus
|
||||||
|
: appManagedStatus;
|
||||||
|
this.latestStatus = status;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async install(): Promise<CodexRuntimeStatus> {
|
||||||
|
if (this.installPromise) {
|
||||||
|
return this.installPromise;
|
||||||
|
}
|
||||||
|
this.installPromise = this.installInternal().finally(() => {
|
||||||
|
this.installPromise = null;
|
||||||
|
});
|
||||||
|
return this.installPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(status: CodexRuntimeStatus): void {
|
||||||
|
this.latestStatus = status;
|
||||||
|
safeSendToRenderer(this.mainWindow, CHANNEL, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishProgress(progress: CodexRuntimeInstallProgress): void {
|
||||||
|
this.publish({
|
||||||
|
installed: false,
|
||||||
|
source: 'missing',
|
||||||
|
state: progress.phase,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAppManagedStatus(): Promise<CodexRuntimeStatus> {
|
||||||
|
const manifest = await readCurrentManifest();
|
||||||
|
if (!isAbsoluteExistingFile(manifest?.binaryPath)) {
|
||||||
|
return { installed: false, source: 'missing', state: 'idle' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await execCli(manifest.binaryPath, ['--version'], {
|
||||||
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
binaryPath: manifest.binaryPath,
|
||||||
|
version: stdout.trim() || manifest.platformVersion,
|
||||||
|
source: 'app-managed',
|
||||||
|
state: 'ready',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
binaryPath: manifest.binaryPath,
|
||||||
|
version: manifest.platformVersion,
|
||||||
|
source: 'app-managed',
|
||||||
|
state: 'failed',
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPathStatus(): Promise<CodexRuntimeStatus> {
|
||||||
|
const binaryPath = resolvePathCodexBinary();
|
||||||
|
if (!binaryPath) {
|
||||||
|
return { installed: false, source: 'missing', state: 'idle' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||||
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
binaryPath,
|
||||||
|
version: stdout.trim() || undefined,
|
||||||
|
source: 'path',
|
||||||
|
state: 'ready',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
binaryPath,
|
||||||
|
source: 'path',
|
||||||
|
state: 'failed',
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installInternal(): Promise<CodexRuntimeStatus> {
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
try {
|
||||||
|
this.publishProgress({ phase: 'checking', detail: 'Resolving latest Codex package...' });
|
||||||
|
const rootMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME);
|
||||||
|
const candidates = getCodexRuntimePlatformCandidates();
|
||||||
|
const optionalDependencies = rootMetadata.optionalDependencies ?? {};
|
||||||
|
const selected =
|
||||||
|
candidates.find((candidate) => optionalDependencies[candidate.optionalDependencyName]) ??
|
||||||
|
candidates[0];
|
||||||
|
if (!selected) {
|
||||||
|
throw new Error(
|
||||||
|
`No Codex binary package is available for ${process.platform}/${process.arch}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fallbackPlatformVersion = `${rootMetadata.version!}-${selected.platformTag}`;
|
||||||
|
const platformVersion = parsePlatformVersion(
|
||||||
|
optionalDependencies[selected.optionalDependencyName],
|
||||||
|
fallbackPlatformVersion
|
||||||
|
);
|
||||||
|
const platformMetadata = await fetchPackageMetadata(ROOT_PACKAGE_NAME, platformVersion);
|
||||||
|
|
||||||
|
this.publishProgress({
|
||||||
|
phase: 'downloading',
|
||||||
|
detail: `Downloading Codex ${platformMetadata.version}...`,
|
||||||
|
});
|
||||||
|
const tarball = await downloadTarball(platformMetadata.dist!.tarball!, (progress) => {
|
||||||
|
this.publishProgress(progress);
|
||||||
|
});
|
||||||
|
verifyCodexRuntimePackageIntegrity(tarball, platformMetadata.dist!.integrity!);
|
||||||
|
|
||||||
|
this.publishProgress({ phase: 'installing', detail: 'Extracting Codex runtime...' });
|
||||||
|
const files = extractCodexRuntimePackageFilesFromTarball(tarball, selected.vendorTarget);
|
||||||
|
const runtimeRoot = getRuntimeRootPath();
|
||||||
|
tempDir = path.join(runtimeRoot, `installing-${process.pid}-${randomUUID()}`);
|
||||||
|
const versionDir = path.join(
|
||||||
|
runtimeRoot,
|
||||||
|
'versions',
|
||||||
|
platformMetadata.version!,
|
||||||
|
selected.vendorTarget
|
||||||
|
);
|
||||||
|
const binaryPath = path.join(versionDir, 'codex', getExecutableName());
|
||||||
|
|
||||||
|
await fsp.rm(tempDir, { recursive: true, force: true });
|
||||||
|
await fsp.mkdir(tempDir, { recursive: true });
|
||||||
|
await writePackageFiles(tempDir, files);
|
||||||
|
|
||||||
|
this.publishProgress({ phase: 'installing', detail: 'Verifying Codex binary...' });
|
||||||
|
const tempBinaryPath = path.join(tempDir, 'codex', getExecutableName());
|
||||||
|
const { stdout } = await execCli(tempBinaryPath, ['--version'], {
|
||||||
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fsp.rm(versionDir, { recursive: true, force: true });
|
||||||
|
await fsp.mkdir(path.dirname(versionDir), { recursive: true });
|
||||||
|
await fsp.rename(tempDir, versionDir);
|
||||||
|
tempDir = null;
|
||||||
|
const manifest: CodexRuntimeManifest = {
|
||||||
|
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
|
||||||
|
rootVersion: rootMetadata.version!,
|
||||||
|
platformVersion: platformMetadata.version!,
|
||||||
|
platformTarget: selected.vendorTarget,
|
||||||
|
binaryPath,
|
||||||
|
integrity: platformMetadata.dist!.integrity!,
|
||||||
|
installedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await fsp.writeFile(
|
||||||
|
getCurrentManifestPath(),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const status: CodexRuntimeStatus = {
|
||||||
|
installed: true,
|
||||||
|
binaryPath,
|
||||||
|
version: stdout.trim() || manifest.platformVersion,
|
||||||
|
source: 'app-managed',
|
||||||
|
state: 'ready',
|
||||||
|
progress: {
|
||||||
|
phase: 'ready',
|
||||||
|
percent: 100,
|
||||||
|
detail: `Installed Codex ${stdout.trim() || manifest.platformVersion}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.publish(status);
|
||||||
|
return status;
|
||||||
|
} catch (error) {
|
||||||
|
if (tempDir) {
|
||||||
|
await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
const status: CodexRuntimeStatus = {
|
||||||
|
installed: false,
|
||||||
|
source: 'missing',
|
||||||
|
state: 'failed',
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
progress: {
|
||||||
|
phase: 'failed',
|
||||||
|
detail: getErrorMessage(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
logger.error('Failed to install Codex runtime:', status.error);
|
||||||
|
this.publish(status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
CODEX_RUNTIME_GET_STATUS,
|
||||||
|
CODEX_RUNTIME_INSTALL,
|
||||||
|
CODEX_RUNTIME_INVALIDATE_STATUS,
|
||||||
|
CODEX_RUNTIME_PROGRESS,
|
||||||
|
} from '@features/codex-runtime-installer/contracts';
|
||||||
|
|
||||||
|
import type { CodexRuntimeAPI } from '@features/codex-runtime-installer/contracts';
|
||||||
|
import type { IpcRenderer } from 'electron';
|
||||||
|
|
||||||
|
interface CreateCodexRuntimeInstallerBridgeDeps {
|
||||||
|
ipcRenderer: IpcRenderer;
|
||||||
|
invokeIpcWithResult: <T>(channel: string, ...args: unknown[]) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodexRuntimeInstallerBridge({
|
||||||
|
ipcRenderer,
|
||||||
|
invokeIpcWithResult,
|
||||||
|
}: CreateCodexRuntimeInstallerBridgeDeps): CodexRuntimeAPI {
|
||||||
|
return {
|
||||||
|
getStatus: () => invokeIpcWithResult(CODEX_RUNTIME_GET_STATUS),
|
||||||
|
install: () => invokeIpcWithResult(CODEX_RUNTIME_INSTALL),
|
||||||
|
invalidateStatus: () => invokeIpcWithResult(CODEX_RUNTIME_INVALIDATE_STATUS),
|
||||||
|
onProgress: (callback) => {
|
||||||
|
ipcRenderer.on(
|
||||||
|
CODEX_RUNTIME_PROGRESS,
|
||||||
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
||||||
|
);
|
||||||
|
return (): void => {
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
CODEX_RUNTIME_PROGRESS,
|
||||||
|
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/codex-runtime-installer/preload/index.ts
Normal file
1
src/features/codex-runtime-installer/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { createCodexRuntimeInstallerBridge } from './createCodexRuntimeInstallerBridge';
|
||||||
|
|
@ -175,6 +175,7 @@ import {
|
||||||
safeSendToRenderer,
|
safeSendToRenderer,
|
||||||
} from './utils/safeWebContentsSend';
|
} from './utils/safeWebContentsSend';
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
|
import { setCodexRuntimeMainWindow } from './ipc/codexRuntime';
|
||||||
import {
|
import {
|
||||||
ActiveTeamRegistry,
|
ActiveTeamRegistry,
|
||||||
BoardTaskActivityDetailService,
|
BoardTaskActivityDetailService,
|
||||||
|
|
@ -2094,6 +2095,7 @@ function attachMainWindowToServices(): void {
|
||||||
updaterService?.setMainWindow(win);
|
updaterService?.setMainWindow(win);
|
||||||
cliInstallerService?.setMainWindow(win);
|
cliInstallerService?.setMainWindow(win);
|
||||||
openCodeRuntimeInstallerService?.setMainWindow(win);
|
openCodeRuntimeInstallerService?.setMainWindow(win);
|
||||||
|
setCodexRuntimeMainWindow(win);
|
||||||
setTmuxMainWindow(win);
|
setTmuxMainWindow(win);
|
||||||
ptyTerminalService?.setMainWindow(win);
|
ptyTerminalService?.setMainWindow(win);
|
||||||
teamProvisioningService?.setMainWindow(win);
|
teamProvisioningService?.setMainWindow(win);
|
||||||
|
|
@ -2420,6 +2422,7 @@ function createWindow(): void {
|
||||||
if (openCodeRuntimeInstallerService) {
|
if (openCodeRuntimeInstallerService) {
|
||||||
openCodeRuntimeInstallerService.setMainWindow(null);
|
openCodeRuntimeInstallerService.setMainWindow(null);
|
||||||
}
|
}
|
||||||
|
setCodexRuntimeMainWindow(null);
|
||||||
setTmuxMainWindow(null);
|
setTmuxMainWindow(null);
|
||||||
if (ptyTerminalService) {
|
if (ptyTerminalService) {
|
||||||
ptyTerminalService.setMainWindow(null);
|
ptyTerminalService.setMainWindow(null);
|
||||||
|
|
|
||||||
25
src/main/ipc/codexRuntime.ts
Normal file
25
src/main/ipc/codexRuntime.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
createCodexRuntimeInstallerFeature,
|
||||||
|
registerCodexRuntimeInstallerIpc,
|
||||||
|
removeCodexRuntimeInstallerIpc,
|
||||||
|
} from '@features/codex-runtime-installer/main';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { BrowserWindow, IpcMain } from 'electron';
|
||||||
|
|
||||||
|
const logger = createLogger('IPC:codexRuntime');
|
||||||
|
const codexRuntimeInstallerFeature = createCodexRuntimeInstallerFeature();
|
||||||
|
|
||||||
|
export function registerCodexRuntimeHandlers(ipcMain: IpcMain): void {
|
||||||
|
registerCodexRuntimeInstallerIpc(ipcMain, codexRuntimeInstallerFeature);
|
||||||
|
logger.info('Codex runtime handlers registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCodexRuntimeHandlers(ipcMain: IpcMain): void {
|
||||||
|
removeCodexRuntimeInstallerIpc(ipcMain);
|
||||||
|
logger.info('Codex runtime handlers removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCodexRuntimeMainWindow(window: BrowserWindow | null): void {
|
||||||
|
codexRuntimeInstallerFeature.setMainWindow(window);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
registerCliInstallerHandlers,
|
registerCliInstallerHandlers,
|
||||||
removeCliInstallerHandlers,
|
removeCliInstallerHandlers,
|
||||||
} from './cliInstaller';
|
} from './cliInstaller';
|
||||||
|
import { registerCodexRuntimeHandlers, removeCodexRuntimeHandlers } from './codexRuntime';
|
||||||
import { initializeConfigHandlers, registerConfigHandlers, removeConfigHandlers } from './config';
|
import { initializeConfigHandlers, registerConfigHandlers, removeConfigHandlers } from './config';
|
||||||
import {
|
import {
|
||||||
initializeContextHandlers,
|
initializeContextHandlers,
|
||||||
|
|
@ -273,6 +274,7 @@ export function initializeIpcHandlers(
|
||||||
if (openCodeRuntimeInstaller) {
|
if (openCodeRuntimeInstaller) {
|
||||||
registerOpenCodeRuntimeHandlers(ipcMain);
|
registerOpenCodeRuntimeHandlers(ipcMain);
|
||||||
}
|
}
|
||||||
|
registerCodexRuntimeHandlers(ipcMain);
|
||||||
if (ptyTerminal) {
|
if (ptyTerminal) {
|
||||||
registerTerminalHandlers(ipcMain);
|
registerTerminalHandlers(ipcMain);
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +317,7 @@ export function removeIpcHandlers(): void {
|
||||||
removeScheduleHandlers(ipcMain);
|
removeScheduleHandlers(ipcMain);
|
||||||
removeCliInstallerHandlers(ipcMain);
|
removeCliInstallerHandlers(ipcMain);
|
||||||
removeOpenCodeRuntimeHandlers(ipcMain);
|
removeOpenCodeRuntimeHandlers(ipcMain);
|
||||||
|
removeCodexRuntimeHandlers(ipcMain);
|
||||||
removeTerminalHandlers(ipcMain);
|
removeTerminalHandlers(ipcMain);
|
||||||
removeTmuxHandlers(ipcMain);
|
removeTmuxHandlers(ipcMain);
|
||||||
removeHttpServerHandlers(ipcMain);
|
removeHttpServerHandlers(ipcMain);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { constants as fsConstants } from 'node:fs';
|
||||||
import * as fsp from 'node:fs/promises';
|
import * as fsp from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
|
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
const CACHE_VERIFY_TTL_MS = 30_000;
|
const CACHE_VERIFY_TTL_MS = 30_000;
|
||||||
const VERSION_CACHE_TTL_MS = 30_000;
|
const VERSION_CACHE_TTL_MS = 30_000;
|
||||||
|
|
@ -52,7 +54,18 @@ function isPathLikeCandidate(candidate: string): boolean {
|
||||||
|
|
||||||
function getPathEntries(): string[] {
|
function getPathEntries(): string[] {
|
||||||
const delimiter = process.platform === 'win32' ? ';' : path.delimiter;
|
const delimiter = process.platform === 'win32' ? ';' : path.delimiter;
|
||||||
return (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
const shellEnv = getCachedShellEnv() ?? {};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return [shellEnv.PATH, process.env.PATH]
|
||||||
|
.flatMap((pathValue) => (pathValue ?? '').split(delimiter))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!entry || seen.has(entry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(entry);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePathEntryCandidate(pathEntry: string, candidate: string): string {
|
function resolvePathEntryCandidate(pathEntry: string, candidate: string): string {
|
||||||
|
|
@ -98,6 +111,13 @@ export class CodexBinaryResolver {
|
||||||
static async resolve(): Promise<string | null> {
|
static async resolve(): Promise<string | null> {
|
||||||
if (cachedBinaryPath !== undefined) {
|
if (cachedBinaryPath !== undefined) {
|
||||||
if (cachedBinaryPath === null) {
|
if (cachedBinaryPath === null) {
|
||||||
|
const verifiedAppManagedBinaryPath =
|
||||||
|
await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||||
|
if (verifiedAppManagedBinaryPath) {
|
||||||
|
cachedBinaryPath = verifiedAppManagedBinaryPath;
|
||||||
|
cacheVerifiedAt = Date.now();
|
||||||
|
return verifiedAppManagedBinaryPath;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +146,12 @@ export class CodexBinaryResolver {
|
||||||
|
|
||||||
private static async runResolve(): Promise<string | null> {
|
private static async runResolve(): Promise<string | null> {
|
||||||
const override = process.env.CODEX_CLI_PATH?.trim();
|
const override = process.env.CODEX_CLI_PATH?.trim();
|
||||||
const candidates = override ? [override, 'codex'] : ['codex'];
|
const appManagedBinaryPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||||
|
const candidates = [
|
||||||
|
...(override ? [override] : []),
|
||||||
|
...(appManagedBinaryPath ? [appManagedBinaryPath] : []),
|
||||||
|
'codex',
|
||||||
|
];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const resolved = await verifyBinary(candidate);
|
const resolved = await verifyBinary(candidate);
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { PathLike } from 'node:fs';
|
import type { PathLike } from 'node:fs';
|
||||||
|
|
||||||
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
||||||
|
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise<string | null>>();
|
||||||
|
|
||||||
vi.mock('node:fs/promises', () => ({
|
vi.mock('node:fs/promises', () => ({
|
||||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@features/codex-runtime-installer/main', () => ({
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPath: () =>
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
const originalPath = process.env.PATH;
|
const originalPath = process.env.PATH;
|
||||||
const originalPathExt = process.env.PATHEXT;
|
const originalPathExt = process.env.PATHEXT;
|
||||||
|
|
@ -32,6 +38,7 @@ describe('CodexBinaryResolver', () => {
|
||||||
setPlatform('win32');
|
setPlatform('win32');
|
||||||
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
||||||
delete process.env.CODEX_CLI_PATH;
|
delete process.env.CODEX_CLI_PATH;
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -78,4 +85,45 @@ describe('CodexBinaryResolver', () => {
|
||||||
|
|
||||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers a verified app-managed Codex binary before PATH lookup', async () => {
|
||||||
|
const appManagedBinary = 'C:\\Users\\tester\\AppData\\Roaming\\AgentTeams\\codex.exe';
|
||||||
|
const pathBinary = 'C:\\Program Files\\nodejs\\codex.cmd';
|
||||||
|
process.env.PATH = 'C:\\Program Files\\nodejs';
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(appManagedBinary);
|
||||||
|
|
||||||
|
accessMock.mockImplementation((filePath) => {
|
||||||
|
if (filePath === appManagedBinary || filePath === pathBinary) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||||
|
CodexBinaryResolver.clearCache();
|
||||||
|
|
||||||
|
await expect(CodexBinaryResolver.resolve()).resolves.toBe(appManagedBinary);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers a negative cache entry when a verified app-managed Codex binary appears', async () => {
|
||||||
|
const appManagedBinary = 'C:\\Users\\tester\\AppData\\Roaming\\AgentTeams\\codex.exe';
|
||||||
|
process.env.PATH = '';
|
||||||
|
|
||||||
|
accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
|
||||||
|
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||||
|
CodexBinaryResolver.clearCache();
|
||||||
|
|
||||||
|
await expect(CodexBinaryResolver.resolve()).resolves.toBeNull();
|
||||||
|
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(appManagedBinary);
|
||||||
|
accessMock.mockImplementation((filePath) => {
|
||||||
|
if (filePath === appManagedBinary) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(CodexBinaryResolver.resolve()).resolves.toBe(appManagedBinary);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
|
||||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
|
import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
|
||||||
|
|
@ -49,6 +50,14 @@ export async function buildProviderAwareCliEnv(
|
||||||
) {
|
) {
|
||||||
env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
|
env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
|
||||||
}
|
}
|
||||||
|
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||||
|
if (
|
||||||
|
appManagedCodexBinary &&
|
||||||
|
!env.CODEX_CLI_PATH &&
|
||||||
|
(!resolvedProviderId || resolvedProviderId === 'codex')
|
||||||
|
) {
|
||||||
|
env.CODEX_CLI_PATH = appManagedCodexBinary;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.providerId) {
|
if (options.providerId) {
|
||||||
if (!resolvedProviderId) {
|
if (!resolvedProviderId) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
||||||
|
import { createCodexRuntimeInstallerBridge } from '@features/codex-runtime-installer/preload';
|
||||||
import { createMemberLogStreamBridge } from '@features/member-log-stream/preload';
|
import { createMemberLogStreamBridge } from '@features/member-log-stream/preload';
|
||||||
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
|
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
|
||||||
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
||||||
|
|
@ -1592,6 +1593,9 @@ const electronAPI: ElectronAPI = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ===== Codex Runtime Installer API =====
|
||||||
|
codexRuntime: createCodexRuntimeInstallerBridge({ ipcRenderer, invokeIpcWithResult }),
|
||||||
|
|
||||||
tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }),
|
tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }),
|
||||||
|
|
||||||
// ===== Terminal API =====
|
// ===== Terminal API =====
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
CodexAccountSnapshotDto,
|
CodexAccountSnapshotDto,
|
||||||
CodexStartChatgptLoginOptions,
|
CodexStartChatgptLoginOptions,
|
||||||
} from '@features/codex-account/contracts';
|
} from '@features/codex-account/contracts';
|
||||||
|
import type { CodexRuntimeAPI } from '@features/codex-runtime-installer/contracts';
|
||||||
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||||
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
||||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||||
|
|
@ -1276,6 +1277,24 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
codexRuntime: CodexRuntimeAPI = {
|
||||||
|
getStatus: async () => ({
|
||||||
|
installed: false,
|
||||||
|
source: 'missing',
|
||||||
|
state: 'idle',
|
||||||
|
}),
|
||||||
|
install: async () => ({
|
||||||
|
installed: false,
|
||||||
|
source: 'missing',
|
||||||
|
state: 'failed',
|
||||||
|
error: 'Codex runtime installer is not available in browser mode',
|
||||||
|
}),
|
||||||
|
invalidateStatus: async (): Promise<void> => {},
|
||||||
|
onProgress: (): (() => void) => {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
runtimeProviderManagement: RuntimeProviderManagementApi = {
|
runtimeProviderManagement: RuntimeProviderManagementApi = {
|
||||||
loadView: async (input) => ({
|
loadView: async (input) => ({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ import {
|
||||||
} from './providerDashboardRateLimits';
|
} from './providerDashboardRateLimits';
|
||||||
|
|
||||||
import type { DashboardRateLimitItem } from './providerDashboardRateLimits';
|
import type { DashboardRateLimitItem } from './providerDashboardRateLimits';
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
import type {
|
import type {
|
||||||
CliProviderAuthMode,
|
CliProviderAuthMode,
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
|
|
@ -357,9 +358,12 @@ interface InstalledBannerProps {
|
||||||
anthropicRateLimitsRefreshing: boolean;
|
anthropicRateLimitsRefreshing: boolean;
|
||||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||||
openCodeRuntimeStatusLoading: boolean;
|
openCodeRuntimeStatusLoading: boolean;
|
||||||
|
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||||
|
codexRuntimeStatusLoading: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onOpenCodeInstall: () => void;
|
onOpenCodeInstall: () => void;
|
||||||
|
onCodexInstall: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onToggleProvidersCollapsed: () => void;
|
onToggleProvidersCollapsed: () => void;
|
||||||
onProviderLogin: (providerId: CliProviderId) => void;
|
onProviderLogin: (providerId: CliProviderId) => void;
|
||||||
|
|
@ -592,8 +596,41 @@ function shouldShowOpenCodeInstallAction(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpenCodeRuntimeInstalling(
|
function shouldShowCodexInstallAction(
|
||||||
status: OpenCodeRuntimeStatus | null,
|
provider: CliProviderStatus,
|
||||||
|
showSkeleton: boolean,
|
||||||
|
codexRuntimeStatus: CodexRuntimeStatus | null
|
||||||
|
): boolean {
|
||||||
|
const codexNativeBackend = provider.availableBackends?.find(
|
||||||
|
(backend) => backend.id === 'codex-native'
|
||||||
|
);
|
||||||
|
const runtimeMissingText = [
|
||||||
|
provider.statusMessage,
|
||||||
|
provider.detailMessage,
|
||||||
|
codexNativeBackend?.statusMessage,
|
||||||
|
codexNativeBackend?.detailMessage,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
const runtimeMissing =
|
||||||
|
provider.verificationState === 'error' &&
|
||||||
|
(codexNativeBackend?.state === 'runtime-missing' ||
|
||||||
|
runtimeMissingText.includes('codex cli not found') ||
|
||||||
|
runtimeMissingText.includes('runtime missing'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
provider.providerId === 'codex' &&
|
||||||
|
!showSkeleton &&
|
||||||
|
!provider.authenticated &&
|
||||||
|
runtimeMissing &&
|
||||||
|
codexRuntimeStatus?.source !== 'path' &&
|
||||||
|
!(codexRuntimeStatus?.source === 'app-managed' && codexRuntimeStatus.state !== 'failed')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRuntimeInstalling(
|
||||||
|
status: OpenCodeRuntimeStatus | CodexRuntimeStatus | null,
|
||||||
loading: boolean
|
loading: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -604,7 +641,7 @@ function isOpenCodeRuntimeInstalling(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenCodeInstallLabel(status: OpenCodeRuntimeStatus | null): string {
|
function getRuntimeInstallLabel(status: OpenCodeRuntimeStatus | CodexRuntimeStatus | null): string {
|
||||||
if (status?.state === 'downloading') {
|
if (status?.state === 'downloading') {
|
||||||
const percent = status.progress?.percent;
|
const percent = status.progress?.percent;
|
||||||
return typeof percent === 'number' ? `Downloading ${percent}%` : 'Downloading';
|
return typeof percent === 'number' ? `Downloading ${percent}%` : 'Downloading';
|
||||||
|
|
@ -641,9 +678,12 @@ const InstalledBanner = ({
|
||||||
anthropicRateLimitsRefreshing,
|
anthropicRateLimitsRefreshing,
|
||||||
openCodeRuntimeStatus,
|
openCodeRuntimeStatus,
|
||||||
openCodeRuntimeStatusLoading,
|
openCodeRuntimeStatusLoading,
|
||||||
|
codexRuntimeStatus,
|
||||||
|
codexRuntimeStatusLoading,
|
||||||
isBusy,
|
isBusy,
|
||||||
onInstall,
|
onInstall,
|
||||||
onOpenCodeInstall,
|
onOpenCodeInstall,
|
||||||
|
onCodexInstall,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onToggleProvidersCollapsed,
|
onToggleProvidersCollapsed,
|
||||||
onProviderLogin,
|
onProviderLogin,
|
||||||
|
|
@ -957,6 +997,33 @@ const InstalledBanner = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-start gap-2">
|
<div className="flex shrink-0 items-start gap-2">
|
||||||
|
{shouldShowCodexInstallAction(provider, showSkeleton, codexRuntimeStatus) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCodexInstall}
|
||||||
|
disabled={isRuntimeInstalling(
|
||||||
|
codexRuntimeStatus,
|
||||||
|
codexRuntimeStatusLoading
|
||||||
|
)}
|
||||||
|
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.34)',
|
||||||
|
color: '#86efac',
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
codexRuntimeStatus?.error ??
|
||||||
|
codexRuntimeStatus?.progress?.detail ??
|
||||||
|
'Install Codex CLI into app data'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isRuntimeInstalling(codexRuntimeStatus, codexRuntimeStatusLoading) ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="size-3" />
|
||||||
|
)}
|
||||||
|
{getRuntimeInstallLabel(codexRuntimeStatus)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{shouldShowOpenCodeInstallAction(
|
{shouldShowOpenCodeInstallAction(
|
||||||
provider,
|
provider,
|
||||||
showSkeleton,
|
showSkeleton,
|
||||||
|
|
@ -965,7 +1032,7 @@ const InstalledBanner = ({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenCodeInstall}
|
onClick={onOpenCodeInstall}
|
||||||
disabled={isOpenCodeRuntimeInstalling(
|
disabled={isRuntimeInstalling(
|
||||||
openCodeRuntimeStatus,
|
openCodeRuntimeStatus,
|
||||||
openCodeRuntimeStatusLoading
|
openCodeRuntimeStatusLoading
|
||||||
)}
|
)}
|
||||||
|
|
@ -980,7 +1047,7 @@ const InstalledBanner = ({
|
||||||
'Install OpenCode CLI into app data'
|
'Install OpenCode CLI into app data'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isOpenCodeRuntimeInstalling(
|
{isRuntimeInstalling(
|
||||||
openCodeRuntimeStatus,
|
openCodeRuntimeStatus,
|
||||||
openCodeRuntimeStatusLoading
|
openCodeRuntimeStatusLoading
|
||||||
) ? (
|
) ? (
|
||||||
|
|
@ -988,7 +1055,7 @@ const InstalledBanner = ({
|
||||||
) : (
|
) : (
|
||||||
<Download className="size-3" />
|
<Download className="size-3" />
|
||||||
)}
|
)}
|
||||||
{getOpenCodeInstallLabel(openCodeRuntimeStatus)}
|
{getRuntimeInstallLabel(openCodeRuntimeStatus)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
|
|
@ -1106,12 +1173,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
completedVersion,
|
completedVersion,
|
||||||
openCodeRuntimeStatus,
|
openCodeRuntimeStatus,
|
||||||
openCodeRuntimeStatusLoading,
|
openCodeRuntimeStatusLoading,
|
||||||
|
codexRuntimeStatus,
|
||||||
|
codexRuntimeStatusLoading,
|
||||||
bootstrapCliStatus,
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
fetchCliProviderStatus,
|
fetchCliProviderStatus,
|
||||||
invalidateCliStatus,
|
invalidateCliStatus,
|
||||||
installCli,
|
installCli,
|
||||||
installOpenCodeRuntime,
|
installOpenCodeRuntime,
|
||||||
|
installCodexRuntime,
|
||||||
isBusy,
|
isBusy,
|
||||||
} = useCliInstaller();
|
} = useCliInstaller();
|
||||||
|
|
||||||
|
|
@ -1546,9 +1616,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||||
|
codexRuntimeStatus={codexRuntimeStatus}
|
||||||
|
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||||
|
onCodexInstall={() => void installCodexRuntime()}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
|
|
@ -1778,9 +1851,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||||
|
codexRuntimeStatus={codexRuntimeStatus}
|
||||||
|
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||||
|
onCodexInstall={() => void installCodexRuntime()}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
|
|
@ -1844,9 +1920,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||||
|
codexRuntimeStatus={codexRuntimeStatus}
|
||||||
|
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||||
|
onCodexInstall={() => void installCodexRuntime()}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
|
|
@ -2070,9 +2149,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||||
|
codexRuntimeStatus={codexRuntimeStatus}
|
||||||
|
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||||
|
onCodexInstall={() => void installCodexRuntime()}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types';
|
import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types';
|
||||||
|
|
||||||
export function useCliInstaller(): {
|
export function useCliInstaller(): {
|
||||||
|
|
@ -33,6 +34,9 @@ export function useCliInstaller(): {
|
||||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||||
openCodeRuntimeStatusLoading: boolean;
|
openCodeRuntimeStatusLoading: boolean;
|
||||||
openCodeRuntimeError: string | null;
|
openCodeRuntimeError: string | null;
|
||||||
|
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||||
|
codexRuntimeStatusLoading: boolean;
|
||||||
|
codexRuntimeError: string | null;
|
||||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
fetchCliProviderStatus: (
|
fetchCliProviderStatus: (
|
||||||
|
|
@ -44,6 +48,9 @@ export function useCliInstaller(): {
|
||||||
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
||||||
installOpenCodeRuntime: () => Promise<void>;
|
installOpenCodeRuntime: () => Promise<void>;
|
||||||
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
||||||
|
fetchCodexRuntimeStatus: () => Promise<void>;
|
||||||
|
installCodexRuntime: () => Promise<void>;
|
||||||
|
invalidateCodexRuntimeStatus: () => Promise<void>;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
} {
|
} {
|
||||||
const {
|
const {
|
||||||
|
|
@ -62,6 +69,9 @@ export function useCliInstaller(): {
|
||||||
openCodeRuntimeStatus,
|
openCodeRuntimeStatus,
|
||||||
openCodeRuntimeStatusLoading,
|
openCodeRuntimeStatusLoading,
|
||||||
openCodeRuntimeError,
|
openCodeRuntimeError,
|
||||||
|
codexRuntimeStatus,
|
||||||
|
codexRuntimeStatusLoading,
|
||||||
|
codexRuntimeError,
|
||||||
bootstrapCliStatus,
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
fetchCliProviderStatus,
|
fetchCliProviderStatus,
|
||||||
|
|
@ -70,6 +80,9 @@ export function useCliInstaller(): {
|
||||||
fetchOpenCodeRuntimeStatus,
|
fetchOpenCodeRuntimeStatus,
|
||||||
installOpenCodeRuntime,
|
installOpenCodeRuntime,
|
||||||
invalidateOpenCodeRuntimeStatus,
|
invalidateOpenCodeRuntimeStatus,
|
||||||
|
fetchCodexRuntimeStatus,
|
||||||
|
installCodexRuntime,
|
||||||
|
invalidateCodexRuntimeStatus,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
cliStatus: s.cliStatus,
|
cliStatus: s.cliStatus,
|
||||||
|
|
@ -87,6 +100,9 @@ export function useCliInstaller(): {
|
||||||
openCodeRuntimeStatus: s.openCodeRuntimeStatus,
|
openCodeRuntimeStatus: s.openCodeRuntimeStatus,
|
||||||
openCodeRuntimeStatusLoading: s.openCodeRuntimeStatusLoading,
|
openCodeRuntimeStatusLoading: s.openCodeRuntimeStatusLoading,
|
||||||
openCodeRuntimeError: s.openCodeRuntimeError,
|
openCodeRuntimeError: s.openCodeRuntimeError,
|
||||||
|
codexRuntimeStatus: s.codexRuntimeStatus,
|
||||||
|
codexRuntimeStatusLoading: s.codexRuntimeStatusLoading,
|
||||||
|
codexRuntimeError: s.codexRuntimeError,
|
||||||
bootstrapCliStatus: s.bootstrapCliStatus,
|
bootstrapCliStatus: s.bootstrapCliStatus,
|
||||||
fetchCliStatus: s.fetchCliStatus,
|
fetchCliStatus: s.fetchCliStatus,
|
||||||
fetchCliProviderStatus: s.fetchCliProviderStatus,
|
fetchCliProviderStatus: s.fetchCliProviderStatus,
|
||||||
|
|
@ -95,6 +111,9 @@ export function useCliInstaller(): {
|
||||||
fetchOpenCodeRuntimeStatus: s.fetchOpenCodeRuntimeStatus,
|
fetchOpenCodeRuntimeStatus: s.fetchOpenCodeRuntimeStatus,
|
||||||
installOpenCodeRuntime: s.installOpenCodeRuntime,
|
installOpenCodeRuntime: s.installOpenCodeRuntime,
|
||||||
invalidateOpenCodeRuntimeStatus: s.invalidateOpenCodeRuntimeStatus,
|
invalidateOpenCodeRuntimeStatus: s.invalidateOpenCodeRuntimeStatus,
|
||||||
|
fetchCodexRuntimeStatus: s.fetchCodexRuntimeStatus,
|
||||||
|
installCodexRuntime: s.installCodexRuntime,
|
||||||
|
invalidateCodexRuntimeStatus: s.invalidateCodexRuntimeStatus,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -117,6 +136,9 @@ export function useCliInstaller(): {
|
||||||
openCodeRuntimeStatus,
|
openCodeRuntimeStatus,
|
||||||
openCodeRuntimeStatusLoading,
|
openCodeRuntimeStatusLoading,
|
||||||
openCodeRuntimeError,
|
openCodeRuntimeError,
|
||||||
|
codexRuntimeStatus,
|
||||||
|
codexRuntimeStatusLoading,
|
||||||
|
codexRuntimeError,
|
||||||
bootstrapCliStatus,
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
fetchCliProviderStatus,
|
fetchCliProviderStatus,
|
||||||
|
|
@ -125,6 +147,9 @@ export function useCliInstaller(): {
|
||||||
fetchOpenCodeRuntimeStatus,
|
fetchOpenCodeRuntimeStatus,
|
||||||
installOpenCodeRuntime,
|
installOpenCodeRuntime,
|
||||||
invalidateOpenCodeRuntimeStatus,
|
invalidateOpenCodeRuntimeStatus,
|
||||||
|
fetchCodexRuntimeStatus,
|
||||||
|
installCodexRuntime,
|
||||||
|
invalidateCodexRuntimeStatus,
|
||||||
isBusy,
|
isBusy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
getModelOnlyFallbackProviderIds,
|
getModelOnlyFallbackProviderIds,
|
||||||
mergeCliStatusPreservingHydratedProviders,
|
mergeCliStatusPreservingHydratedProviders,
|
||||||
reconcileMultimodelProviderLoading,
|
reconcileMultimodelProviderLoading,
|
||||||
|
refreshCodexProviderStatusAfterRuntimeInstall,
|
||||||
|
refreshOpenCodeProviderStatusAfterRuntimeInstall,
|
||||||
} from './slices/cliInstallerSlice';
|
} from './slices/cliInstallerSlice';
|
||||||
import { createConfigSlice } from './slices/configSlice';
|
import { createConfigSlice } from './slices/configSlice';
|
||||||
import { createConnectionSlice } from './slices/connectionSlice';
|
import { createConnectionSlice } from './slices/connectionSlice';
|
||||||
|
|
@ -65,6 +67,7 @@ import {
|
||||||
|
|
||||||
import type { DetectedError } from '../types/data';
|
import type { DetectedError } from '../types/data';
|
||||||
import type { AppState } from './types';
|
import type { AppState } from './types';
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
import type {
|
import type {
|
||||||
ActiveToolCall,
|
ActiveToolCall,
|
||||||
CliInstallerProgress,
|
CliInstallerProgress,
|
||||||
|
|
@ -248,6 +251,9 @@ export function initializeNotificationListeners(): () => void {
|
||||||
if (api.openCodeRuntime) {
|
if (api.openCodeRuntime) {
|
||||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||||
}
|
}
|
||||||
|
if (api.codexRuntime) {
|
||||||
|
void useStore.getState().fetchCodexRuntimeStatus();
|
||||||
|
}
|
||||||
|
|
||||||
// Remaining fetches have no data dependency on each other — run in parallel
|
// Remaining fetches have no data dependency on each other — run in parallel
|
||||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||||
|
|
@ -2317,10 +2323,29 @@ export function initializeNotificationListeners(): () => void {
|
||||||
});
|
});
|
||||||
if (status.installed && status.state === 'ready') {
|
if (status.installed && status.state === 'ready') {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await api.cliInstaller?.invalidateStatus();
|
await refreshOpenCodeProviderStatusAfterRuntimeInstall(() => useStore.getState());
|
||||||
await useStore.getState().fetchCliProviderStatus('opencode', {
|
})();
|
||||||
silent: false,
|
}
|
||||||
});
|
});
|
||||||
|
if (typeof cleanup === 'function') {
|
||||||
|
cleanupFns.push(cleanup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (api.codexRuntime?.onProgress) {
|
||||||
|
const cleanup = api.codexRuntime.onProgress((_event: unknown, data: unknown) => {
|
||||||
|
const status = data as CodexRuntimeStatus;
|
||||||
|
useStore.setState({
|
||||||
|
codexRuntimeStatus: status,
|
||||||
|
codexRuntimeError: status.error ?? null,
|
||||||
|
codexRuntimeStatusLoading:
|
||||||
|
status.state === 'checking' ||
|
||||||
|
status.state === 'downloading' ||
|
||||||
|
status.state === 'installing',
|
||||||
|
});
|
||||||
|
if (status.installed && status.state === 'ready') {
|
||||||
|
void (async () => {
|
||||||
|
await refreshCodexProviderStatusAfterRuntimeInstall(() => useStore.getState());
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||||
import type {
|
import type {
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
|
|
@ -19,6 +20,11 @@ const logger = createLogger('Store:cliInstaller');
|
||||||
|
|
||||||
/** Max log lines to keep in UI (reserved for future use) */
|
/** Max log lines to keep in UI (reserved for future use) */
|
||||||
const _MAX_LOG_LINES = 50;
|
const _MAX_LOG_LINES = 50;
|
||||||
|
const OPENCODE_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3;
|
||||||
|
const OPENCODE_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
|
||||||
|
const CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3;
|
||||||
|
const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
|
||||||
|
|
||||||
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = [
|
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = [
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'codex',
|
'codex',
|
||||||
|
|
@ -106,6 +112,66 @@ function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefi
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCliProviderStatusInFlight(providerId: CliProviderId): void {
|
||||||
|
cliProviderStatusInFlight.delete(`${providerId}:status`);
|
||||||
|
cliProviderStatusInFlight.delete(`${providerId}:verify`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderStatus(
|
||||||
|
status: CliInstallationStatus | null | undefined,
|
||||||
|
providerId: CliProviderId
|
||||||
|
): CliProviderStatus | undefined {
|
||||||
|
return status?.providers.find((provider) => provider.providerId === providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOpenCodeModels(provider: CliProviderStatus | undefined): boolean {
|
||||||
|
return provider?.providerId === 'opencode' && provider.models.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCodexRuntimeReady(provider: CliProviderStatus | undefined): boolean {
|
||||||
|
return (
|
||||||
|
provider?.providerId === 'codex' &&
|
||||||
|
provider.availableBackends?.some((backend) => backend.id === 'codex-native') === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenCodeRuntimeMissingSnapshot(provider: CliProviderStatus | undefined): boolean {
|
||||||
|
if (!provider || provider.providerId !== 'opencode' || provider.models.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `${provider.statusMessage ?? ''} ${provider.detailMessage ?? ''}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
provider.verificationState === 'error' &&
|
||||||
|
message.includes('opencode cli') &&
|
||||||
|
(message.includes('not found') ||
|
||||||
|
message.includes('not installed') ||
|
||||||
|
message.includes('missing'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPreserveCurrentProviderStatus(
|
||||||
|
currentProvider: CliProviderStatus | undefined,
|
||||||
|
incomingProvider: CliProviderStatus
|
||||||
|
): boolean {
|
||||||
|
if (!currentProvider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOpenCodeModels(currentProvider) && isOpenCodeRuntimeMissingSnapshot(incomingProvider)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
isHydratedMultimodelProviderStatus(currentProvider) &&
|
||||||
|
!isHydratedMultimodelProviderStatus(incomingProvider)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getIncompleteMultimodelProviderIds(
|
export function getIncompleteMultimodelProviderIds(
|
||||||
status: CliInstallationStatus | null
|
status: CliInstallationStatus | null
|
||||||
): CliProviderId[] {
|
): CliProviderId[] {
|
||||||
|
|
@ -165,11 +231,7 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
const incomingProviderIds = new Set(incoming.providers.map((provider) => provider.providerId));
|
const incomingProviderIds = new Set(incoming.providers.map((provider) => provider.providerId));
|
||||||
const providers = incoming.providers.map((incomingProvider) => {
|
const providers = incoming.providers.map((incomingProvider) => {
|
||||||
const currentProvider = currentProvidersById.get(incomingProvider.providerId);
|
const currentProvider = currentProvidersById.get(incomingProvider.providerId);
|
||||||
if (
|
if (currentProvider && shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
||||||
currentProvider &&
|
|
||||||
isHydratedMultimodelProviderStatus(currentProvider) &&
|
|
||||||
!isHydratedMultimodelProviderStatus(incomingProvider)
|
|
||||||
) {
|
|
||||||
return currentProvider;
|
return currentProvider;
|
||||||
}
|
}
|
||||||
return incomingProvider;
|
return incomingProvider;
|
||||||
|
|
@ -194,6 +256,52 @@ export function mergeCliStatusPreservingHydratedProviders(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshOpenCodeProviderStatusAfterRuntimeInstall(
|
||||||
|
get: () => Pick<CliInstallerSlice, 'cliStatus' | 'fetchCliProviderStatus'>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!api.cliInstaller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= OPENCODE_PROVIDER_INSTALL_REFRESH_ATTEMPTS; attempt += 1) {
|
||||||
|
await api.cliInstaller.invalidateStatus();
|
||||||
|
clearCliProviderStatusInFlight('opencode');
|
||||||
|
const epoch = ++cliStatusEpoch;
|
||||||
|
await get().fetchCliProviderStatus('opencode', { silent: false, epoch });
|
||||||
|
|
||||||
|
if (hasOpenCodeModels(getProviderStatus(get().cliStatus, 'opencode'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < OPENCODE_PROVIDER_INSTALL_REFRESH_ATTEMPTS) {
|
||||||
|
await sleep(OPENCODE_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshCodexProviderStatusAfterRuntimeInstall(
|
||||||
|
get: () => Pick<CliInstallerSlice, 'cliStatus' | 'fetchCliProviderStatus'>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!api.cliInstaller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS; attempt += 1) {
|
||||||
|
await api.cliInstaller.invalidateStatus();
|
||||||
|
clearCliProviderStatusInFlight('codex');
|
||||||
|
const epoch = ++cliStatusEpoch;
|
||||||
|
await get().fetchCliProviderStatus('codex', { silent: false, epoch });
|
||||||
|
|
||||||
|
if (hasCodexRuntimeReady(getProviderStatus(get().cliStatus, 'codex'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS) {
|
||||||
|
await sleep(CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isMultimodelCliStatus(
|
function isMultimodelCliStatus(
|
||||||
status: CliInstallationStatus | null | undefined
|
status: CliInstallationStatus | null | undefined
|
||||||
): status is CliInstallationStatus & { flavor: 'agent_teams_orchestrator' } {
|
): status is CliInstallationStatus & { flavor: 'agent_teams_orchestrator' } {
|
||||||
|
|
@ -291,6 +399,9 @@ export interface CliInstallerSlice {
|
||||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||||
openCodeRuntimeStatusLoading: boolean;
|
openCodeRuntimeStatusLoading: boolean;
|
||||||
openCodeRuntimeError: string | null;
|
openCodeRuntimeError: string | null;
|
||||||
|
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||||
|
codexRuntimeStatusLoading: boolean;
|
||||||
|
codexRuntimeError: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||||
|
|
@ -304,6 +415,9 @@ export interface CliInstallerSlice {
|
||||||
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
||||||
installOpenCodeRuntime: () => Promise<void>;
|
installOpenCodeRuntime: () => Promise<void>;
|
||||||
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
||||||
|
fetchCodexRuntimeStatus: () => Promise<void>;
|
||||||
|
installCodexRuntime: () => Promise<void>;
|
||||||
|
invalidateCodexRuntimeStatus: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cliStatusInFlight: Promise<void> | null = null;
|
let cliStatusInFlight: Promise<void> | null = null;
|
||||||
|
|
@ -311,6 +425,7 @@ const cliProviderStatusInFlight = new Map<string, Promise<void>>();
|
||||||
let cliStatusEpoch = 0;
|
let cliStatusEpoch = 0;
|
||||||
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
||||||
let openCodeRuntimeStatusInFlight: Promise<void> | null = null;
|
let openCodeRuntimeStatusInFlight: Promise<void> | null = null;
|
||||||
|
let codexRuntimeStatusInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Slice Creator
|
// Slice Creator
|
||||||
|
|
@ -337,6 +452,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
openCodeRuntimeStatus: null,
|
openCodeRuntimeStatus: null,
|
||||||
openCodeRuntimeStatusLoading: false,
|
openCodeRuntimeStatusLoading: false,
|
||||||
openCodeRuntimeError: null,
|
openCodeRuntimeError: null,
|
||||||
|
codexRuntimeStatus: null,
|
||||||
|
codexRuntimeStatusLoading: false,
|
||||||
|
codexRuntimeError: null,
|
||||||
|
|
||||||
bootstrapCliStatus: async (options) => {
|
bootstrapCliStatus: async (options) => {
|
||||||
if (!api.cliInstaller) return;
|
if (!api.cliInstaller) return;
|
||||||
|
|
@ -749,9 +867,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
set({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null });
|
set({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null });
|
||||||
if (status.installed) {
|
if (status.installed) {
|
||||||
await api.openCodeRuntime.invalidateStatus();
|
await api.openCodeRuntime.invalidateStatus();
|
||||||
await api.cliInstaller?.invalidateStatus();
|
await refreshOpenCodeProviderStatusAfterRuntimeInstall(get);
|
||||||
const epoch = ++cliStatusEpoch;
|
|
||||||
await get().fetchCliProviderStatus('opencode', { silent: false, epoch });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to install OpenCode runtime';
|
const message = error instanceof Error ? error.message : 'Failed to install OpenCode runtime';
|
||||||
|
|
@ -766,4 +882,63 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
await api.openCodeRuntime?.invalidateStatus();
|
await api.openCodeRuntime?.invalidateStatus();
|
||||||
set({ openCodeRuntimeStatus: null });
|
set({ openCodeRuntimeStatus: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchCodexRuntimeStatus: async () => {
|
||||||
|
if (!api.codexRuntime) return;
|
||||||
|
if (codexRuntimeStatusInFlight) return codexRuntimeStatusInFlight;
|
||||||
|
|
||||||
|
codexRuntimeStatusInFlight = (async () => {
|
||||||
|
set({ codexRuntimeStatusLoading: true, codexRuntimeError: null });
|
||||||
|
try {
|
||||||
|
const status = await api.codexRuntime.getStatus();
|
||||||
|
set({ codexRuntimeStatus: status, codexRuntimeError: status.error ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Failed to check Codex runtime status';
|
||||||
|
logger.error('Failed to fetch Codex runtime status:', error);
|
||||||
|
set({ codexRuntimeError: message });
|
||||||
|
} finally {
|
||||||
|
set({ codexRuntimeStatusLoading: false });
|
||||||
|
codexRuntimeStatusInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return codexRuntimeStatusInFlight;
|
||||||
|
},
|
||||||
|
|
||||||
|
installCodexRuntime: async () => {
|
||||||
|
if (!api.codexRuntime) return;
|
||||||
|
set({
|
||||||
|
codexRuntimeStatusLoading: true,
|
||||||
|
codexRuntimeError: null,
|
||||||
|
codexRuntimeStatus: {
|
||||||
|
installed: false,
|
||||||
|
source: 'missing',
|
||||||
|
state: 'checking',
|
||||||
|
progress: {
|
||||||
|
phase: 'checking',
|
||||||
|
detail: 'Resolving latest Codex package...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const status = await api.codexRuntime.install();
|
||||||
|
set({ codexRuntimeStatus: status, codexRuntimeError: status.error ?? null });
|
||||||
|
if (status.installed) {
|
||||||
|
await api.codexRuntime.invalidateStatus();
|
||||||
|
await refreshCodexProviderStatusAfterRuntimeInstall(get);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to install Codex runtime';
|
||||||
|
logger.error('Failed to install Codex runtime:', error);
|
||||||
|
set({ codexRuntimeError: message });
|
||||||
|
} finally {
|
||||||
|
set({ codexRuntimeStatusLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
invalidateCodexRuntimeStatus: async () => {
|
||||||
|
await api.codexRuntime?.invalidateStatus();
|
||||||
|
set({ codexRuntimeStatus: null });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ import type { TerminalAPI } from './terminal';
|
||||||
import type { TmuxAPI } from './tmux';
|
import type { TmuxAPI } from './tmux';
|
||||||
import type { WaterfallData } from './visualization';
|
import type { WaterfallData } from './visualization';
|
||||||
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
|
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
|
||||||
|
import type { CodexRuntimeAPI } from '@features/codex-runtime-installer/contracts';
|
||||||
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncMetricsRequest,
|
MemberWorkSyncMetricsRequest,
|
||||||
|
|
@ -930,6 +931,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
|
||||||
// OpenCode app-managed runtime installer API
|
// OpenCode app-managed runtime installer API
|
||||||
openCodeRuntime: OpenCodeRuntimeAPI;
|
openCodeRuntime: OpenCodeRuntimeAPI;
|
||||||
|
|
||||||
|
// Codex app-managed runtime installer API
|
||||||
|
codexRuntime: CodexRuntimeAPI;
|
||||||
|
|
||||||
// Runtime nested provider management API
|
// Runtime nested provider management API
|
||||||
runtimeProviderManagement: RuntimeProviderManagementApi;
|
runtimeProviderManagement: RuntimeProviderManagementApi;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { 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());
|
||||||
|
|
||||||
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
|
execCli: execCliMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractCodexRuntimePackageFilesFromTarball,
|
||||||
|
getCodexRuntimePlatformCandidates,
|
||||||
|
resolveAppManagedCodexRuntimeBinaryPath,
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPath,
|
||||||
|
verifyCodexRuntimePackageIntegrity,
|
||||||
|
} from '@features/codex-runtime-installer/main';
|
||||||
|
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
|
let tempRoot: string | null = null;
|
||||||
|
|
||||||
|
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
|
||||||
|
const encoded = value
|
||||||
|
.toString(8)
|
||||||
|
.padStart(length - 1, '0')
|
||||||
|
.slice(-(length - 1));
|
||||||
|
header.write(`${encoded}\0`, offset, length, 'ascii');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTarEntry(name: string, data: Buffer): Buffer {
|
||||||
|
const header = Buffer.alloc(512);
|
||||||
|
header.write(name, 0, Math.min(Buffer.byteLength(name), 100), 'utf8');
|
||||||
|
writeOctal(header, 100, 8, 0o755);
|
||||||
|
writeOctal(header, 108, 8, 0);
|
||||||
|
writeOctal(header, 116, 8, 0);
|
||||||
|
writeOctal(header, 124, 12, data.length);
|
||||||
|
writeOctal(header, 136, 12, 0);
|
||||||
|
header.fill(' ', 148, 156);
|
||||||
|
header.write('0', 156, 1, 'ascii');
|
||||||
|
header.write('ustar\0', 257, 6, 'ascii');
|
||||||
|
header.write('00', 263, 2, 'ascii');
|
||||||
|
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||||
|
const checksumText = checksum.toString(8).padStart(6, '0');
|
||||||
|
header.write(`${checksumText}\0 `, 148, 8, 'ascii');
|
||||||
|
|
||||||
|
const padding = Buffer.alloc((512 - (data.length % 512)) % 512);
|
||||||
|
return Buffer.concat([header, data, padding]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTarball(entries: { name: string; data: string }[]): Buffer {
|
||||||
|
return gzipSync(
|
||||||
|
Buffer.concat([
|
||||||
|
...entries.map((entry) => createTarEntry(entry.name, Buffer.from(entry.data))),
|
||||||
|
Buffer.alloc(1024),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodexRuntimeInstallerService resolver', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-runtime-resolver-'));
|
||||||
|
setAppDataBasePath(tempRoot);
|
||||||
|
execCliMock.mockReset();
|
||||||
|
execCliMock.mockResolvedValue({ stdout: 'codex-cli 1.0.0\n', stderr: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
setAppDataBasePath(null);
|
||||||
|
if (tempRoot) {
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
tempRoot = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the current app-managed Codex binary path only when manifest and binary exist', async () => {
|
||||||
|
const binaryPath = path.join(
|
||||||
|
tempRoot!,
|
||||||
|
'data',
|
||||||
|
'runtimes',
|
||||||
|
'codex',
|
||||||
|
'versions',
|
||||||
|
'1.0.0-darwin-arm64',
|
||||||
|
'aarch64-apple-darwin',
|
||||||
|
'codex',
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'codex', 'current.json');
|
||||||
|
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||||
|
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||||
|
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||||
|
await writeFile(
|
||||||
|
manifestPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
rootVersion: '1.0.0',
|
||||||
|
platformVersion: '1.0.0-darwin-arm64',
|
||||||
|
platformTarget: 'aarch64-apple-darwin',
|
||||||
|
binaryPath,
|
||||||
|
integrity: 'sha512-test',
|
||||||
|
installedAt: '2026-05-13T00:00:00.000Z',
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolveAppManagedCodexRuntimeBinaryPath()).toBe(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a manifest whose binary path is missing', async () => {
|
||||||
|
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'codex', 'current.json');
|
||||||
|
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
manifestPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
rootVersion: '1.0.0',
|
||||||
|
platformVersion: '1.0.0-darwin-arm64',
|
||||||
|
platformTarget: 'aarch64-apple-darwin',
|
||||||
|
binaryPath: path.join(tempRoot!, 'missing-codex'),
|
||||||
|
integrity: 'sha512-test',
|
||||||
|
installedAt: '2026-05-13T00:00:00.000Z',
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolveAppManagedCodexRuntimeBinaryPath()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the verified app-managed binary path only when --version succeeds', async () => {
|
||||||
|
const binaryPath = path.join(
|
||||||
|
tempRoot!,
|
||||||
|
'data',
|
||||||
|
'runtimes',
|
||||||
|
'codex',
|
||||||
|
'versions',
|
||||||
|
'1.0.0-darwin-arm64',
|
||||||
|
'aarch64-apple-darwin',
|
||||||
|
'codex',
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
const manifestPath = path.join(tempRoot!, 'data', 'runtimes', 'codex', 'current.json');
|
||||||
|
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||||
|
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||||
|
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||||
|
await writeFile(
|
||||||
|
manifestPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
rootVersion: '1.0.0',
|
||||||
|
platformVersion: '1.0.0-darwin-arm64',
|
||||||
|
platformTarget: 'aarch64-apple-darwin',
|
||||||
|
binaryPath,
|
||||||
|
integrity: 'sha512-test',
|
||||||
|
installedAt: '2026-05-13T00:00:00.000Z',
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(resolveVerifiedAppManagedCodexRuntimeBinaryPath()).resolves.toBe(binaryPath);
|
||||||
|
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
|
||||||
|
timeout: 10_000,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
execCliMock.mockRejectedValueOnce(new Error('broken binary'));
|
||||||
|
|
||||||
|
await expect(resolveVerifiedAppManagedCodexRuntimeBinaryPath()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CodexRuntimeInstallerService package safety helpers', () => {
|
||||||
|
it('selects expected platform packages', () => {
|
||||||
|
expect(
|
||||||
|
getCodexRuntimePlatformCandidates('darwin', 'arm64').map(
|
||||||
|
(item) => item.optionalDependencyName
|
||||||
|
)
|
||||||
|
).toEqual(['@openai/codex-darwin-arm64']);
|
||||||
|
expect(
|
||||||
|
getCodexRuntimePlatformCandidates('darwin', 'x64').map((item) => item.vendorTarget)
|
||||||
|
).toEqual(['x86_64-apple-darwin']);
|
||||||
|
expect(
|
||||||
|
getCodexRuntimePlatformCandidates('linux', 'x64').map((item) => item.vendorTarget)
|
||||||
|
).toEqual(['x86_64-unknown-linux-musl']);
|
||||||
|
expect(
|
||||||
|
getCodexRuntimePlatformCandidates('linux', 'arm64').map((item) => item.vendorTarget)
|
||||||
|
).toEqual(['aarch64-unknown-linux-musl']);
|
||||||
|
expect(
|
||||||
|
getCodexRuntimePlatformCandidates('win32', 'x64').map((item) => item.vendorTarget)
|
||||||
|
).toEqual(['x86_64-pc-windows-msvc']);
|
||||||
|
expect(
|
||||||
|
getCodexRuntimePlatformCandidates('win32', 'arm64').map((item) => item.vendorTarget)
|
||||||
|
).toEqual(['aarch64-pc-windows-msvc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails npm integrity mismatches', () => {
|
||||||
|
const payload = Buffer.from('actual package');
|
||||||
|
const wrongHash = createHash('sha512').update('different package').digest('base64');
|
||||||
|
|
||||||
|
expect(() => verifyCodexRuntimePackageIntegrity(payload, `sha512-${wrongHash}`)).toThrow(
|
||||||
|
'integrity check failed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the full selected Codex vendor payload from the package tarball', () => {
|
||||||
|
const tarball = createTarball([
|
||||||
|
{ name: 'package/vendor/other-target/codex/codex', data: 'wrong' },
|
||||||
|
{ name: 'package/vendor/aarch64-apple-darwin/codex/codex', data: 'codex-binary' },
|
||||||
|
{ name: 'package/vendor/aarch64-apple-darwin/path/rg', data: 'rg-binary' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const files = extractCodexRuntimePackageFilesFromTarball(
|
||||||
|
tarball,
|
||||||
|
'aarch64-apple-darwin',
|
||||||
|
'codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(files.map((file) => file.relativePath).sort((a, b) => a.localeCompare(b))).toEqual([
|
||||||
|
'codex/codex',
|
||||||
|
'path/rg',
|
||||||
|
]);
|
||||||
|
expect(files.find((file) => file.relativePath === 'codex/codex')?.data.toString()).toBe(
|
||||||
|
'codex-binary'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tar path traversal before extraction', () => {
|
||||||
|
const tarball = createTarball([
|
||||||
|
{ name: '../codex', data: 'unsafe' },
|
||||||
|
{ name: 'package/vendor/aarch64-apple-darwin/codex/codex', data: 'right' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
extractCodexRuntimePackageFilesFromTarball(tarball, 'aarch64-apple-darwin', 'codex')
|
||||||
|
).toThrow('Unsafe Codex package tar entry');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ const applyAllConfiguredConnectionEnvMock = vi.fn();
|
||||||
const getConfiguredConnectionIssuesMock = vi.fn();
|
const getConfiguredConnectionIssuesMock = vi.fn();
|
||||||
const getConfiguredConnectionLaunchArgsMock = vi.fn();
|
const getConfiguredConnectionLaunchArgsMock = vi.fn();
|
||||||
const resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock = vi.fn();
|
const resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock = vi.fn();
|
||||||
|
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('@main/utils/cliEnv', () => ({
|
vi.mock('@main/utils/cliEnv', () => ({
|
||||||
buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) =>
|
buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) =>
|
||||||
|
|
@ -62,6 +63,11 @@ vi.mock('../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerSe
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock(),
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@features/codex-runtime-installer/main', () => ({
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPath: () =>
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('buildProviderAwareCliEnv', () => {
|
describe('buildProviderAwareCliEnv', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
@ -88,6 +94,7 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
|
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
|
||||||
getConfiguredConnectionIssuesMock.mockResolvedValue({});
|
getConfiguredConnectionIssuesMock.mockResolvedValue({});
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds provider-pinned CLI env and returns provider-specific issues', async () => {
|
it('builds provider-pinned CLI env and returns provider-specific issues', async () => {
|
||||||
|
|
@ -332,4 +339,58 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
|
|
||||||
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
|
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('injects the verified app-managed Codex binary for Codex launches', async () => {
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(
|
||||||
|
'/Users/tester/App Support/runtimes/codex/current/codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { buildProviderAwareCliEnv } =
|
||||||
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
const result = await buildProviderAwareCliEnv({
|
||||||
|
providerId: 'codex',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
CODEX_CLI_PATH: '/Users/tester/App Support/runtimes/codex/current/codex',
|
||||||
|
}),
|
||||||
|
'codex',
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(result.env.CODEX_CLI_PATH).toBe(
|
||||||
|
'/Users/tester/App Support/runtimes/codex/current/codex'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves explicit CODEX_CLI_PATH over the app-managed Codex binary', async () => {
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(
|
||||||
|
'/Users/tester/App Support/runtimes/codex/current/codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { buildProviderAwareCliEnv } =
|
||||||
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
const result = await buildProviderAwareCliEnv({
|
||||||
|
providerId: 'codex',
|
||||||
|
env: {
|
||||||
|
CODEX_CLI_PATH: '/custom/codex',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.env.CODEX_CLI_PATH).toBe('/custom/codex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inject the app-managed Codex binary into non-Codex provider launches', async () => {
|
||||||
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(
|
||||||
|
'/Users/tester/App Support/runtimes/codex/current/codex'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { buildProviderAwareCliEnv } =
|
||||||
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
const result = await buildProviderAwareCliEnv({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.env.CODEX_CLI_PATH).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ describe('cliInstallerSlice', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -299,6 +300,49 @@ describe('cliInstallerSlice', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not let stale OpenCode missing-CLI status overwrite a refreshed model list', () => {
|
||||||
|
const current = createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
models: ['opencode/minimax-m2.5-free'],
|
||||||
|
canLoginFromUi: false,
|
||||||
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const incoming = createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
supported: false,
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'error',
|
||||||
|
statusMessage: 'OpenCode CLI not found',
|
||||||
|
models: [],
|
||||||
|
canLoginFromUi: false,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: false,
|
||||||
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
|
},
|
||||||
|
backend: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||||
|
|
||||||
|
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
||||||
|
{
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
models: ['opencode/minimax-m2.5-free'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('still allows real OpenCode runtime errors to replace previous ready status', () => {
|
it('still allows real OpenCode runtime errors to replace previous ready status', () => {
|
||||||
const current = createMultimodelStatus([
|
const current = createMultimodelStatus([
|
||||||
createMultimodelProvider({
|
createMultimodelProvider({
|
||||||
|
|
@ -406,6 +450,72 @@ describe('cliInstallerSlice', () => {
|
||||||
models: ['opencode/big-pickle'],
|
models: ['opencode/big-pickle'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries OpenCode provider refresh after install until models appear', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const stale = createMultimodelProvider({
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
supported: false,
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'error',
|
||||||
|
statusMessage: 'OpenCode CLI not found',
|
||||||
|
models: [],
|
||||||
|
canLoginFromUi: false,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: false,
|
||||||
|
oneShot: false,
|
||||||
|
extensions: createDefaultCliExtensionCapabilities(),
|
||||||
|
},
|
||||||
|
backend: null,
|
||||||
|
});
|
||||||
|
const refreshed = createMultimodelProvider({
|
||||||
|
providerId: 'opencode',
|
||||||
|
displayName: 'OpenCode',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
models: ['opencode/big-pickle'],
|
||||||
|
canLoginFromUi: false,
|
||||||
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||||
|
});
|
||||||
|
|
||||||
|
useStore.setState({
|
||||||
|
cliStatus: createMultimodelStatus([stale]),
|
||||||
|
});
|
||||||
|
vi.mocked(api.openCodeRuntime.install).mockResolvedValue({
|
||||||
|
installed: true,
|
||||||
|
binaryPath: '/Users/tester/App Support/runtimes/opencode/current/opencode',
|
||||||
|
version: '1.14.48',
|
||||||
|
source: 'app-managed',
|
||||||
|
state: 'ready',
|
||||||
|
});
|
||||||
|
vi.mocked(api.cliInstaller.getProviderStatus)
|
||||||
|
.mockResolvedValueOnce(stale)
|
||||||
|
.mockResolvedValueOnce(refreshed);
|
||||||
|
|
||||||
|
const installPromise = useStore.getState().installOpenCodeRuntime();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
await installPromise;
|
||||||
|
|
||||||
|
expect(api.cliInstaller.invalidateStatus).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(2);
|
||||||
|
expect(
|
||||||
|
useStore
|
||||||
|
.getState()
|
||||||
|
.cliStatus?.providers.find((provider) => provider.providerId === 'opencode')
|
||||||
|
).toMatchObject({
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
models: ['opencode/big-pickle'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchCliStatus', () => {
|
describe('fetchCliStatus', () => {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
},
|
},
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "test/**/*", "scripts/team-changes-real-data-smoke.ts"],
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"test/**/*",
|
||||||
|
"scripts/team-changes-real-data-smoke.ts",
|
||||||
|
"scripts/smoke/codex-runtime-install.ts"
|
||||||
|
],
|
||||||
"exclude": ["node_modules", "dist", "dist-electron"]
|
"exclude": ["node_modules", "dist", "dist-electron"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue