feat: add managed codex runtime installer

This commit is contained in:
777genius 2026-05-13 22:30:25 +03:00
parent 6e67e9b3a4
commit a474076330
34 changed files with 2013 additions and 21 deletions

View 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

View file

@ -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",

View 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;
});

View 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;
}

View file

@ -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';

View 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;
}

View file

@ -0,0 +1,3 @@
export type * from './api';
export * from './channels';
export type * from './dto';

View file

@ -0,0 +1,7 @@
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
export interface CodexRuntimeInstallerPort {
getStatus: () => Promise<CodexRuntimeStatus>;
install: () => Promise<CodexRuntimeStatus>;
invalidateStatusCache: () => void;
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1 @@
export type * from './contracts';

View file

@ -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 };
}
}

View file

@ -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);
},
};
}

View 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';

View file

@ -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;
}
}
}

View file

@ -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
);
};
},
};
}

View file

@ -0,0 +1 @@
export { createCodexRuntimeInstallerBridge } from './createCodexRuntimeInstallerBridge';

View file

@ -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);

View 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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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) {

View file

@ -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 =====

View file

@ -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,

View file

@ -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}

View file

@ -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,
};
}

View file

@ -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());
})();
}
});

View file

@ -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 });
},
});

View file

@ -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;

View file

@ -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');
});
});

View file

@ -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();
});
});

View file

@ -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', () => {

View file

@ -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"]
}