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-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",
|
||||
"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",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
|
||||
"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,
|
||||
} from './utils/safeWebContentsSend';
|
||||
import { syncTelemetryFlag } from './sentry';
|
||||
import { setCodexRuntimeMainWindow } from './ipc/codexRuntime';
|
||||
import {
|
||||
ActiveTeamRegistry,
|
||||
BoardTaskActivityDetailService,
|
||||
|
|
@ -2094,6 +2095,7 @@ function attachMainWindowToServices(): void {
|
|||
updaterService?.setMainWindow(win);
|
||||
cliInstallerService?.setMainWindow(win);
|
||||
openCodeRuntimeInstallerService?.setMainWindow(win);
|
||||
setCodexRuntimeMainWindow(win);
|
||||
setTmuxMainWindow(win);
|
||||
ptyTerminalService?.setMainWindow(win);
|
||||
teamProvisioningService?.setMainWindow(win);
|
||||
|
|
@ -2420,6 +2422,7 @@ function createWindow(): void {
|
|||
if (openCodeRuntimeInstallerService) {
|
||||
openCodeRuntimeInstallerService.setMainWindow(null);
|
||||
}
|
||||
setCodexRuntimeMainWindow(null);
|
||||
setTmuxMainWindow(null);
|
||||
if (ptyTerminalService) {
|
||||
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,
|
||||
removeCliInstallerHandlers,
|
||||
} from './cliInstaller';
|
||||
import { registerCodexRuntimeHandlers, removeCodexRuntimeHandlers } from './codexRuntime';
|
||||
import { initializeConfigHandlers, registerConfigHandlers, removeConfigHandlers } from './config';
|
||||
import {
|
||||
initializeContextHandlers,
|
||||
|
|
@ -273,6 +274,7 @@ export function initializeIpcHandlers(
|
|||
if (openCodeRuntimeInstaller) {
|
||||
registerOpenCodeRuntimeHandlers(ipcMain);
|
||||
}
|
||||
registerCodexRuntimeHandlers(ipcMain);
|
||||
if (ptyTerminal) {
|
||||
registerTerminalHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -315,6 +317,7 @@ export function removeIpcHandlers(): void {
|
|||
removeScheduleHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeOpenCodeRuntimeHandlers(ipcMain);
|
||||
removeCodexRuntimeHandlers(ipcMain);
|
||||
removeTerminalHandlers(ipcMain);
|
||||
removeTmuxHandlers(ipcMain);
|
||||
removeHttpServerHandlers(ipcMain);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { constants as fsConstants } from 'node:fs';
|
|||
import * as fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
const CACHE_VERIFY_TTL_MS = 30_000;
|
||||
const VERSION_CACHE_TTL_MS = 30_000;
|
||||
|
|
@ -52,7 +54,18 @@ function isPathLikeCandidate(candidate: string): boolean {
|
|||
|
||||
function getPathEntries(): string[] {
|
||||
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 {
|
||||
|
|
@ -98,6 +111,13 @@ export class CodexBinaryResolver {
|
|||
static async resolve(): Promise<string | null> {
|
||||
if (cachedBinaryPath !== undefined) {
|
||||
if (cachedBinaryPath === null) {
|
||||
const verifiedAppManagedBinaryPath =
|
||||
await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||
if (verifiedAppManagedBinaryPath) {
|
||||
cachedBinaryPath = verifiedAppManagedBinaryPath;
|
||||
cacheVerifiedAt = Date.now();
|
||||
return verifiedAppManagedBinaryPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +146,12 @@ export class CodexBinaryResolver {
|
|||
|
||||
private static async runResolve(): Promise<string | null> {
|
||||
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) {
|
||||
const resolved = await verifyBinary(candidate);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import type { PathLike } from 'node:fs';
|
||||
|
||||
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
||||
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise<string | null>>();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||
}));
|
||||
|
||||
vi.mock('@features/codex-runtime-installer/main', () => ({
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPath: () =>
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(),
|
||||
}));
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
|
|
@ -32,6 +38,7 @@ describe('CodexBinaryResolver', () => {
|
|||
setPlatform('win32');
|
||||
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
||||
delete process.env.CODEX_CLI_PATH;
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -78,4 +85,45 @@ describe('CodexBinaryResolver', () => {
|
|||
|
||||
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 { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
|
||||
|
|
@ -49,6 +50,14 @@ export async function buildProviderAwareCliEnv(
|
|||
) {
|
||||
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 (!resolvedProviderId) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
||||
import { createCodexRuntimeInstallerBridge } from '@features/codex-runtime-installer/preload';
|
||||
import { createMemberLogStreamBridge } from '@features/member-log-stream/preload';
|
||||
import { createMemberWorkSyncBridge } from '@features/member-work-sync/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 }),
|
||||
|
||||
// ===== Terminal API =====
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
CodexAccountSnapshotDto,
|
||||
CodexStartChatgptLoginOptions,
|
||||
} 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 { DashboardRecentProjectsPayload } from '@features/recent-projects/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 = {
|
||||
loadView: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import {
|
|||
} from './providerDashboardRateLimits';
|
||||
|
||||
import type { DashboardRateLimitItem } from './providerDashboardRateLimits';
|
||||
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||
import type {
|
||||
CliProviderAuthMode,
|
||||
CliProviderId,
|
||||
|
|
@ -357,9 +358,12 @@ interface InstalledBannerProps {
|
|||
anthropicRateLimitsRefreshing: boolean;
|
||||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||
openCodeRuntimeStatusLoading: boolean;
|
||||
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||
codexRuntimeStatusLoading: boolean;
|
||||
isBusy: boolean;
|
||||
onInstall: () => void;
|
||||
onOpenCodeInstall: () => void;
|
||||
onCodexInstall: () => void;
|
||||
onRefresh: () => void;
|
||||
onToggleProvidersCollapsed: () => void;
|
||||
onProviderLogin: (providerId: CliProviderId) => void;
|
||||
|
|
@ -592,8 +596,41 @@ function shouldShowOpenCodeInstallAction(
|
|||
);
|
||||
}
|
||||
|
||||
function isOpenCodeRuntimeInstalling(
|
||||
status: OpenCodeRuntimeStatus | null,
|
||||
function shouldShowCodexInstallAction(
|
||||
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
|
||||
): boolean {
|
||||
return (
|
||||
|
|
@ -604,7 +641,7 @@ function isOpenCodeRuntimeInstalling(
|
|||
);
|
||||
}
|
||||
|
||||
function getOpenCodeInstallLabel(status: OpenCodeRuntimeStatus | null): string {
|
||||
function getRuntimeInstallLabel(status: OpenCodeRuntimeStatus | CodexRuntimeStatus | null): string {
|
||||
if (status?.state === 'downloading') {
|
||||
const percent = status.progress?.percent;
|
||||
return typeof percent === 'number' ? `Downloading ${percent}%` : 'Downloading';
|
||||
|
|
@ -641,9 +678,12 @@ const InstalledBanner = ({
|
|||
anthropicRateLimitsRefreshing,
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
codexRuntimeStatus,
|
||||
codexRuntimeStatusLoading,
|
||||
isBusy,
|
||||
onInstall,
|
||||
onOpenCodeInstall,
|
||||
onCodexInstall,
|
||||
onRefresh,
|
||||
onToggleProvidersCollapsed,
|
||||
onProviderLogin,
|
||||
|
|
@ -957,6 +997,33 @@ const InstalledBanner = ({
|
|||
) : null}
|
||||
</div>
|
||||
<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(
|
||||
provider,
|
||||
showSkeleton,
|
||||
|
|
@ -965,7 +1032,7 @@ const InstalledBanner = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onOpenCodeInstall}
|
||||
disabled={isOpenCodeRuntimeInstalling(
|
||||
disabled={isRuntimeInstalling(
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading
|
||||
)}
|
||||
|
|
@ -980,7 +1047,7 @@ const InstalledBanner = ({
|
|||
'Install OpenCode CLI into app data'
|
||||
}
|
||||
>
|
||||
{isOpenCodeRuntimeInstalling(
|
||||
{isRuntimeInstalling(
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading
|
||||
) ? (
|
||||
|
|
@ -988,7 +1055,7 @@ const InstalledBanner = ({
|
|||
) : (
|
||||
<Download className="size-3" />
|
||||
)}
|
||||
{getOpenCodeInstallLabel(openCodeRuntimeStatus)}
|
||||
{getRuntimeInstallLabel(openCodeRuntimeStatus)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
|
|
@ -1106,12 +1173,15 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
completedVersion,
|
||||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
codexRuntimeStatus,
|
||||
codexRuntimeStatusLoading,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
invalidateCliStatus,
|
||||
installCli,
|
||||
installOpenCodeRuntime,
|
||||
installCodexRuntime,
|
||||
isBusy,
|
||||
} = useCliInstaller();
|
||||
|
||||
|
|
@ -1546,9 +1616,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
codexRuntimeStatus={codexRuntimeStatus}
|
||||
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onCodexInstall={() => void installCodexRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
@ -1778,9 +1851,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
codexRuntimeStatus={codexRuntimeStatus}
|
||||
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onCodexInstall={() => void installCodexRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
@ -1844,9 +1920,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
codexRuntimeStatus={codexRuntimeStatus}
|
||||
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onCodexInstall={() => void installCodexRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
@ -2070,9 +2149,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
openCodeRuntimeStatus={openCodeRuntimeStatus}
|
||||
openCodeRuntimeStatusLoading={openCodeRuntimeStatusLoading}
|
||||
codexRuntimeStatus={codexRuntimeStatus}
|
||||
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onOpenCodeInstall={() => void installOpenCodeRuntime()}
|
||||
onCodexInstall={() => void installCodexRuntime()}
|
||||
onRefresh={handleRefresh}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { useStore } from '@renderer/store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||
import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types';
|
||||
|
||||
export function useCliInstaller(): {
|
||||
|
|
@ -33,6 +34,9 @@ export function useCliInstaller(): {
|
|||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||
openCodeRuntimeStatusLoading: boolean;
|
||||
openCodeRuntimeError: string | null;
|
||||
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||
codexRuntimeStatusLoading: boolean;
|
||||
codexRuntimeError: string | null;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
|
|
@ -44,6 +48,9 @@ export function useCliInstaller(): {
|
|||
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
installOpenCodeRuntime: () => Promise<void>;
|
||||
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
fetchCodexRuntimeStatus: () => Promise<void>;
|
||||
installCodexRuntime: () => Promise<void>;
|
||||
invalidateCodexRuntimeStatus: () => Promise<void>;
|
||||
isBusy: boolean;
|
||||
} {
|
||||
const {
|
||||
|
|
@ -62,6 +69,9 @@ export function useCliInstaller(): {
|
|||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
openCodeRuntimeError,
|
||||
codexRuntimeStatus,
|
||||
codexRuntimeStatusLoading,
|
||||
codexRuntimeError,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
|
|
@ -70,6 +80,9 @@ export function useCliInstaller(): {
|
|||
fetchOpenCodeRuntimeStatus,
|
||||
installOpenCodeRuntime,
|
||||
invalidateOpenCodeRuntimeStatus,
|
||||
fetchCodexRuntimeStatus,
|
||||
installCodexRuntime,
|
||||
invalidateCodexRuntimeStatus,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
|
|
@ -87,6 +100,9 @@ export function useCliInstaller(): {
|
|||
openCodeRuntimeStatus: s.openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading: s.openCodeRuntimeStatusLoading,
|
||||
openCodeRuntimeError: s.openCodeRuntimeError,
|
||||
codexRuntimeStatus: s.codexRuntimeStatus,
|
||||
codexRuntimeStatusLoading: s.codexRuntimeStatusLoading,
|
||||
codexRuntimeError: s.codexRuntimeError,
|
||||
bootstrapCliStatus: s.bootstrapCliStatus,
|
||||
fetchCliStatus: s.fetchCliStatus,
|
||||
fetchCliProviderStatus: s.fetchCliProviderStatus,
|
||||
|
|
@ -95,6 +111,9 @@ export function useCliInstaller(): {
|
|||
fetchOpenCodeRuntimeStatus: s.fetchOpenCodeRuntimeStatus,
|
||||
installOpenCodeRuntime: s.installOpenCodeRuntime,
|
||||
invalidateOpenCodeRuntimeStatus: s.invalidateOpenCodeRuntimeStatus,
|
||||
fetchCodexRuntimeStatus: s.fetchCodexRuntimeStatus,
|
||||
installCodexRuntime: s.installCodexRuntime,
|
||||
invalidateCodexRuntimeStatus: s.invalidateCodexRuntimeStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -117,6 +136,9 @@ export function useCliInstaller(): {
|
|||
openCodeRuntimeStatus,
|
||||
openCodeRuntimeStatusLoading,
|
||||
openCodeRuntimeError,
|
||||
codexRuntimeStatus,
|
||||
codexRuntimeStatusLoading,
|
||||
codexRuntimeError,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
|
|
@ -125,6 +147,9 @@ export function useCliInstaller(): {
|
|||
fetchOpenCodeRuntimeStatus,
|
||||
installOpenCodeRuntime,
|
||||
invalidateOpenCodeRuntimeStatus,
|
||||
fetchCodexRuntimeStatus,
|
||||
installCodexRuntime,
|
||||
invalidateCodexRuntimeStatus,
|
||||
isBusy,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import {
|
|||
getModelOnlyFallbackProviderIds,
|
||||
mergeCliStatusPreservingHydratedProviders,
|
||||
reconcileMultimodelProviderLoading,
|
||||
refreshCodexProviderStatusAfterRuntimeInstall,
|
||||
refreshOpenCodeProviderStatusAfterRuntimeInstall,
|
||||
} from './slices/cliInstallerSlice';
|
||||
import { createConfigSlice } from './slices/configSlice';
|
||||
import { createConnectionSlice } from './slices/connectionSlice';
|
||||
|
|
@ -65,6 +67,7 @@ import {
|
|||
|
||||
import type { DetectedError } from '../types/data';
|
||||
import type { AppState } from './types';
|
||||
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||
import type {
|
||||
ActiveToolCall,
|
||||
CliInstallerProgress,
|
||||
|
|
@ -248,6 +251,9 @@ export function initializeNotificationListeners(): () => void {
|
|||
if (api.openCodeRuntime) {
|
||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||
}
|
||||
if (api.codexRuntime) {
|
||||
void useStore.getState().fetchCodexRuntimeStatus();
|
||||
}
|
||||
|
||||
// Remaining fetches have no data dependency on each other — run in parallel
|
||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||
|
|
@ -2317,10 +2323,29 @@ export function initializeNotificationListeners(): () => void {
|
|||
});
|
||||
if (status.installed && status.state === 'ready') {
|
||||
void (async () => {
|
||||
await api.cliInstaller?.invalidateStatus();
|
||||
await useStore.getState().fetchCliProviderStatus('opencode', {
|
||||
silent: false,
|
||||
await refreshOpenCodeProviderStatusAfterRuntimeInstall(() => useStore.getState());
|
||||
})();
|
||||
}
|
||||
});
|
||||
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 type { AppState } from '../types';
|
||||
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliProviderId,
|
||||
|
|
@ -19,6 +20,11 @@ const logger = createLogger('Store:cliInstaller');
|
|||
|
||||
/** Max log lines to keep in UI (reserved for future use) */
|
||||
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[] = [
|
||||
'anthropic',
|
||||
'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(
|
||||
status: CliInstallationStatus | null
|
||||
): CliProviderId[] {
|
||||
|
|
@ -165,11 +231,7 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
const incomingProviderIds = new Set(incoming.providers.map((provider) => provider.providerId));
|
||||
const providers = incoming.providers.map((incomingProvider) => {
|
||||
const currentProvider = currentProvidersById.get(incomingProvider.providerId);
|
||||
if (
|
||||
currentProvider &&
|
||||
isHydratedMultimodelProviderStatus(currentProvider) &&
|
||||
!isHydratedMultimodelProviderStatus(incomingProvider)
|
||||
) {
|
||||
if (currentProvider && shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
||||
return currentProvider;
|
||||
}
|
||||
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(
|
||||
status: CliInstallationStatus | null | undefined
|
||||
): status is CliInstallationStatus & { flavor: 'agent_teams_orchestrator' } {
|
||||
|
|
@ -291,6 +399,9 @@ export interface CliInstallerSlice {
|
|||
openCodeRuntimeStatus: OpenCodeRuntimeStatus | null;
|
||||
openCodeRuntimeStatusLoading: boolean;
|
||||
openCodeRuntimeError: string | null;
|
||||
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||
codexRuntimeStatusLoading: boolean;
|
||||
codexRuntimeError: string | null;
|
||||
|
||||
// Actions
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
|
|
@ -304,6 +415,9 @@ export interface CliInstallerSlice {
|
|||
fetchOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
installOpenCodeRuntime: () => Promise<void>;
|
||||
invalidateOpenCodeRuntimeStatus: () => Promise<void>;
|
||||
fetchCodexRuntimeStatus: () => Promise<void>;
|
||||
installCodexRuntime: () => Promise<void>;
|
||||
invalidateCodexRuntimeStatus: () => Promise<void>;
|
||||
}
|
||||
|
||||
let cliStatusInFlight: Promise<void> | null = null;
|
||||
|
|
@ -311,6 +425,7 @@ const cliProviderStatusInFlight = new Map<string, Promise<void>>();
|
|||
let cliStatusEpoch = 0;
|
||||
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
||||
let openCodeRuntimeStatusInFlight: Promise<void> | null = null;
|
||||
let codexRuntimeStatusInFlight: Promise<void> | null = null;
|
||||
|
||||
// =============================================================================
|
||||
// Slice Creator
|
||||
|
|
@ -337,6 +452,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
openCodeRuntimeStatus: null,
|
||||
openCodeRuntimeStatusLoading: false,
|
||||
openCodeRuntimeError: null,
|
||||
codexRuntimeStatus: null,
|
||||
codexRuntimeStatusLoading: false,
|
||||
codexRuntimeError: null,
|
||||
|
||||
bootstrapCliStatus: async (options) => {
|
||||
if (!api.cliInstaller) return;
|
||||
|
|
@ -749,9 +867,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
set({ openCodeRuntimeStatus: status, openCodeRuntimeError: status.error ?? null });
|
||||
if (status.installed) {
|
||||
await api.openCodeRuntime.invalidateStatus();
|
||||
await api.cliInstaller?.invalidateStatus();
|
||||
const epoch = ++cliStatusEpoch;
|
||||
await get().fetchCliProviderStatus('opencode', { silent: false, epoch });
|
||||
await refreshOpenCodeProviderStatusAfterRuntimeInstall(get);
|
||||
}
|
||||
} catch (error) {
|
||||
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();
|
||||
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 { WaterfallData } from './visualization';
|
||||
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 {
|
||||
MemberWorkSyncMetricsRequest,
|
||||
|
|
@ -930,6 +931,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
|
|||
// OpenCode app-managed runtime installer API
|
||||
openCodeRuntime: OpenCodeRuntimeAPI;
|
||||
|
||||
// Codex app-managed runtime installer API
|
||||
codexRuntime: CodexRuntimeAPI;
|
||||
|
||||
// Runtime nested provider management API
|
||||
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 getConfiguredConnectionLaunchArgsMock = vi.fn();
|
||||
const resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock = vi.fn();
|
||||
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn();
|
||||
|
||||
vi.mock('@main/utils/cliEnv', () => ({
|
||||
buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) =>
|
||||
|
|
@ -62,6 +63,11 @@ vi.mock('../../../../src/main/services/infrastructure/OpenCodeRuntimeInstallerSe
|
|||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@features/codex-runtime-installer/main', () => ({
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPath: () =>
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(),
|
||||
}));
|
||||
|
||||
describe('buildProviderAwareCliEnv', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
|
|
@ -88,6 +94,7 @@ describe('buildProviderAwareCliEnv', () => {
|
|||
getConfiguredConnectionLaunchArgsMock.mockResolvedValue([]);
|
||||
getConfiguredConnectionIssuesMock.mockResolvedValue({});
|
||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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(() => {
|
||||
vi.useRealTimers();
|
||||
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', () => {
|
||||
const current = createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
|
|
@ -406,6 +450,72 @@ describe('cliInstallerSlice', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
},
|
||||
"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"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue