From a474076330b8596e61b12f6241ea094dced225a4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 13 May 2026 22:30:25 +0300 Subject: [PATCH] feat: add managed codex runtime installer --- .github/workflows/codex-runtime-smoke.yml | 66 ++ package.json | 1 + scripts/smoke/codex-runtime-install.ts | 170 +++++ .../codex-runtime-installer/contracts/api.ts | 8 + .../contracts/channels.ts | 4 + .../codex-runtime-installer/contracts/dto.ts | 27 + .../contracts/index.ts | 3 + .../ports/CodexRuntimeInstallerPort.ts | 7 + .../use-cases/GetCodexRuntimeStatusUseCase.ts | 10 + .../use-cases/InstallCodexRuntimeUseCase.ts | 10 + src/features/codex-runtime-installer/index.ts | 1 + .../ipc/registerCodexRuntimeInstallerIpc.ts | 64 ++ .../createCodexRuntimeInstallerFeature.ts | 30 + .../codex-runtime-installer/main/index.ts | 13 + .../CodexRuntimeInstallerService.ts | 679 ++++++++++++++++++ .../createCodexRuntimeInstallerBridge.ts | 37 + .../codex-runtime-installer/preload/index.ts | 1 + src/main/index.ts | 3 + src/main/ipc/codexRuntime.ts | 25 + src/main/ipc/handlers.ts | 3 + .../codexAppServer/CodexBinaryResolver.ts | 29 +- .../__tests__/CodexBinaryResolver.test.ts | 48 ++ .../services/runtime/providerAwareCliEnv.ts | 9 + src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 19 + .../components/dashboard/CliStatusBanner.tsx | 94 ++- src/renderer/hooks/useCliInstaller.ts | 25 + src/renderer/store/index.ts | 33 +- .../store/slices/cliInstallerSlice.ts | 191 ++++- src/shared/types/api.ts | 4 + .../CodexRuntimeInstallerService.test.ts | 238 ++++++ .../runtime/providerAwareCliEnv.test.ts | 61 ++ test/renderer/store/cliInstallerSlice.test.ts | 110 +++ tsconfig.json | 7 +- 34 files changed, 2013 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/codex-runtime-smoke.yml create mode 100644 scripts/smoke/codex-runtime-install.ts create mode 100644 src/features/codex-runtime-installer/contracts/api.ts create mode 100644 src/features/codex-runtime-installer/contracts/channels.ts create mode 100644 src/features/codex-runtime-installer/contracts/dto.ts create mode 100644 src/features/codex-runtime-installer/contracts/index.ts create mode 100644 src/features/codex-runtime-installer/core/application/ports/CodexRuntimeInstallerPort.ts create mode 100644 src/features/codex-runtime-installer/core/application/use-cases/GetCodexRuntimeStatusUseCase.ts create mode 100644 src/features/codex-runtime-installer/core/application/use-cases/InstallCodexRuntimeUseCase.ts create mode 100644 src/features/codex-runtime-installer/index.ts create mode 100644 src/features/codex-runtime-installer/main/adapters/input/ipc/registerCodexRuntimeInstallerIpc.ts create mode 100644 src/features/codex-runtime-installer/main/composition/createCodexRuntimeInstallerFeature.ts create mode 100644 src/features/codex-runtime-installer/main/index.ts create mode 100644 src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts create mode 100644 src/features/codex-runtime-installer/preload/createCodexRuntimeInstallerBridge.ts create mode 100644 src/features/codex-runtime-installer/preload/index.ts create mode 100644 src/main/ipc/codexRuntime.ts create mode 100644 test/main/features/codex-runtime-installer/CodexRuntimeInstallerService.test.ts diff --git a/.github/workflows/codex-runtime-smoke.yml b/.github/workflows/codex-runtime-smoke.yml new file mode 100644 index 00000000..1657aa33 --- /dev/null +++ b/.github/workflows/codex-runtime-smoke.yml @@ -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 diff --git a/package.json b/package.json index b6ee7177..5d4de27f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/smoke/codex-runtime-install.ts b/scripts/smoke/codex-runtime-install.ts new file mode 100644 index 00000000..24a636ff --- /dev/null +++ b/scripts/smoke/codex-runtime-install.ts @@ -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 { + 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 { + 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 { + 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; + }); diff --git a/src/features/codex-runtime-installer/contracts/api.ts b/src/features/codex-runtime-installer/contracts/api.ts new file mode 100644 index 00000000..0618cb04 --- /dev/null +++ b/src/features/codex-runtime-installer/contracts/api.ts @@ -0,0 +1,8 @@ +import type { CodexRuntimeStatus } from './dto'; + +export interface CodexRuntimeAPI { + getStatus: () => Promise; + install: () => Promise; + invalidateStatus: () => Promise; + onProgress: (callback: (event: unknown, data: CodexRuntimeStatus) => void) => () => void; +} diff --git a/src/features/codex-runtime-installer/contracts/channels.ts b/src/features/codex-runtime-installer/contracts/channels.ts new file mode 100644 index 00000000..589a4ff4 --- /dev/null +++ b/src/features/codex-runtime-installer/contracts/channels.ts @@ -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'; diff --git a/src/features/codex-runtime-installer/contracts/dto.ts b/src/features/codex-runtime-installer/contracts/dto.ts new file mode 100644 index 00000000..fd7c9d0a --- /dev/null +++ b/src/features/codex-runtime-installer/contracts/dto.ts @@ -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; +} diff --git a/src/features/codex-runtime-installer/contracts/index.ts b/src/features/codex-runtime-installer/contracts/index.ts new file mode 100644 index 00000000..69f32f5a --- /dev/null +++ b/src/features/codex-runtime-installer/contracts/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; diff --git a/src/features/codex-runtime-installer/core/application/ports/CodexRuntimeInstallerPort.ts b/src/features/codex-runtime-installer/core/application/ports/CodexRuntimeInstallerPort.ts new file mode 100644 index 00000000..1a19425c --- /dev/null +++ b/src/features/codex-runtime-installer/core/application/ports/CodexRuntimeInstallerPort.ts @@ -0,0 +1,7 @@ +import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts'; + +export interface CodexRuntimeInstallerPort { + getStatus: () => Promise; + install: () => Promise; + invalidateStatusCache: () => void; +} diff --git a/src/features/codex-runtime-installer/core/application/use-cases/GetCodexRuntimeStatusUseCase.ts b/src/features/codex-runtime-installer/core/application/use-cases/GetCodexRuntimeStatusUseCase.ts new file mode 100644 index 00000000..8501f909 --- /dev/null +++ b/src/features/codex-runtime-installer/core/application/use-cases/GetCodexRuntimeStatusUseCase.ts @@ -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) {} + + execute(): Promise { + return this.installer.getStatus(); + } +} diff --git a/src/features/codex-runtime-installer/core/application/use-cases/InstallCodexRuntimeUseCase.ts b/src/features/codex-runtime-installer/core/application/use-cases/InstallCodexRuntimeUseCase.ts new file mode 100644 index 00000000..10dacb10 --- /dev/null +++ b/src/features/codex-runtime-installer/core/application/use-cases/InstallCodexRuntimeUseCase.ts @@ -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) {} + + execute(): Promise { + return this.installer.install(); + } +} diff --git a/src/features/codex-runtime-installer/index.ts b/src/features/codex-runtime-installer/index.ts new file mode 100644 index 00000000..f577ca28 --- /dev/null +++ b/src/features/codex-runtime-installer/index.ts @@ -0,0 +1 @@ +export type * from './contracts'; diff --git a/src/features/codex-runtime-installer/main/adapters/input/ipc/registerCodexRuntimeInstallerIpc.ts b/src/features/codex-runtime-installer/main/adapters/input/ipc/registerCodexRuntimeInstallerIpc.ts new file mode 100644 index 00000000..080627b6 --- /dev/null +++ b/src/features/codex-runtime-installer/main/adapters/input/ipc/registerCodexRuntimeInstallerIpc.ts @@ -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> => + withIpcResult(() => feature.getStatus()) + ); + ipcMain.handle( + CODEX_RUNTIME_INSTALL, + (_event: IpcMainInvokeEvent): Promise> => + withIpcResult(() => feature.install()) + ); + ipcMain.handle( + CODEX_RUNTIME_INVALIDATE_STATUS, + (_event: IpcMainInvokeEvent): IpcResult => + 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(work: () => Promise): Promise> { + try { + return { success: true, data: await work() }; + } catch (error) { + const message = getErrorMessage(error); + return { success: false, error: message }; + } +} + +function withSyncIpcResult(work: () => T): IpcResult { + try { + return { success: true, data: work() }; + } catch (error) { + const message = getErrorMessage(error); + return { success: false, error: message }; + } +} diff --git a/src/features/codex-runtime-installer/main/composition/createCodexRuntimeInstallerFeature.ts b/src/features/codex-runtime-installer/main/composition/createCodexRuntimeInstallerFeature.ts new file mode 100644 index 00000000..f4ea4cac --- /dev/null +++ b/src/features/codex-runtime-installer/main/composition/createCodexRuntimeInstallerFeature.ts @@ -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; + install: () => Promise; + 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); + }, + }; +} diff --git a/src/features/codex-runtime-installer/main/index.ts b/src/features/codex-runtime-installer/main/index.ts new file mode 100644 index 00000000..1a6cf8c9 --- /dev/null +++ b/src/features/codex-runtime-installer/main/index.ts @@ -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'; diff --git a/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts new file mode 100644 index 00000000..a1a79b7b --- /dev/null +++ b/src/features/codex-runtime-installer/main/infrastructure/CodexRuntimeInstallerService.ts @@ -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; +} + +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; + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | null = null; + private latestStatus: CodexRuntimeStatus | null = null; + + setMainWindow(win: BrowserWindow | null): void { + this.mainWindow = win; + } + + invalidateStatusCache(): void { + this.latestStatus = null; + } + + async getStatus(): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/features/codex-runtime-installer/preload/createCodexRuntimeInstallerBridge.ts b/src/features/codex-runtime-installer/preload/createCodexRuntimeInstallerBridge.ts new file mode 100644 index 00000000..ab7d78af --- /dev/null +++ b/src/features/codex-runtime-installer/preload/createCodexRuntimeInstallerBridge.ts @@ -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: (channel: string, ...args: unknown[]) => Promise; +} + +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 + ); + }; + }, + }; +} diff --git a/src/features/codex-runtime-installer/preload/index.ts b/src/features/codex-runtime-installer/preload/index.ts new file mode 100644 index 00000000..b9a9f1cb --- /dev/null +++ b/src/features/codex-runtime-installer/preload/index.ts @@ -0,0 +1 @@ +export { createCodexRuntimeInstallerBridge } from './createCodexRuntimeInstallerBridge'; diff --git a/src/main/index.ts b/src/main/index.ts index 47b2e22d..fba84730 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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); diff --git a/src/main/ipc/codexRuntime.ts b/src/main/ipc/codexRuntime.ts new file mode 100644 index 00000000..116bd300 --- /dev/null +++ b/src/main/ipc/codexRuntime.ts @@ -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); +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 29dde1d3..c4eee4e7 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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); diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index ee48a84a..cf5656bd 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -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(); + 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 { 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 { 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); diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts index 863bfbfd..a81b21a6 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -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>(); +const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise>(); 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); + }); }); diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index f4861d6d..506f0b65 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -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) { diff --git a/src/preload/index.ts b/src/preload/index.ts index bbadcf96..37f87d15 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 ===== diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 61ae0cf0..3c8f83e6 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => {}, + onProgress: (): (() => void) => { + return () => {}; + }, + }; + runtimeProviderManagement: RuntimeProviderManagementApi = { loadView: async (input) => ({ schemaVersion: 1, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 49b072be..2145b21f 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -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}
+ {shouldShowCodexInstallAction(provider, showSkeleton, codexRuntimeStatus) ? ( + + ) : null} {shouldShowOpenCodeInstallAction( provider, showSkeleton, @@ -965,7 +1032,7 @@ const InstalledBanner = ({ ) : null}