From 49c7b788c3d1950bfb56f4faa761a5741c28bbb8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:32:47 -0700 Subject: [PATCH] Fix Codex CLI detection for Electron tracking (#496) --- src/main/codex-accounts/service.ts | 3 +- src/main/codex-cli/command.test.ts | 44 ++++++++++ src/main/codex-cli/command.ts | 120 ++++++++++++++++++++++++++ src/main/rate-limits/codex-fetcher.ts | 26 +++--- 4 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 src/main/codex-cli/command.test.ts create mode 100644 src/main/codex-cli/command.ts diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 6362d8c26..d21dcbf2c 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -11,6 +11,7 @@ import type { CodexManagedAccountSummary, CodexRateLimitAccountsState } from '../../shared/types' +import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' @@ -281,7 +282,7 @@ export class CodexAccountService { private async runCodexLogin(managedHomePath: string): Promise { await new Promise((resolvePromise, rejectPromise) => { - const child = spawn('codex', ['login'], { + const child = spawn(resolveCodexCommand(), ['login'], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts new file mode 100644 index 000000000..00561fe98 --- /dev/null +++ b/src/main/codex-cli/command.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveCodexCommand } from './command' + +function makeExecutable(path: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, '') +} + +describe('resolveCodexCommand', () => { + afterEach(() => { + delete process.env.PATH + delete process.env.Path + }) + + it('prefers Codex already present on PATH', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const pathDir = join(root, 'bin') + const commandPath = join(pathDir, 'codex') + makeExecutable(commandPath) + + expect(resolveCodexCommand({ platform: 'darwin', pathEnv: pathDir, homePath: root })).toBe( + commandPath + ) + }) + + it('falls back to the newest nvm-installed Codex when PATH misses it', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'codex') + const v24Path = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'codex') + makeExecutable(v22Path) + makeExecutable(v24Path) + + expect(resolveCodexCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(v24Path) + }) + + it('returns the bare command when no filesystem candidate exists', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-')) + + expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('codex') + }) +}) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts new file mode 100644 index 000000000..8149254ed --- /dev/null +++ b/src/main/codex-cli/command.ts @@ -0,0 +1,120 @@ +import { existsSync, readdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, dirname, join } from 'node:path' + +type ResolveCodexCommandOptions = { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string +} + +function getExecutableNames(platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return ['codex.cmd', 'codex.exe', 'codex.bat', 'codex'] + } + + return ['codex'] +} + +function splitPath(pathEnv: string | null | undefined): string[] { + if (!pathEnv) { + return [] + } + + return pathEnv + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function parseVersionSegment(raw: string): number[] { + return raw + .replace(/^v/i, '') + .split('.') + .map((segment) => Number.parseInt(segment, 10)) + .map((segment) => (Number.isFinite(segment) ? segment : 0)) +} + +function compareVersionDesc(left: string, right: string): number { + const leftParts = parseVersionSegment(left) + const rightParts = parseVersionSegment(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index += 1) { + const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) + if (delta !== 0) { + return delta + } + } + + return right.localeCompare(left) +} + +function findFirstExecutable(directories: string[], executableNames: string[]): string | null { + for (const directory of directories) { + for (const executableName of executableNames) { + const candidate = join(directory, executableName) + if (existsSync(candidate)) { + return candidate + } + } + } + + return null +} + +function getVersionManagerDirectories( + platform: NodeJS.Platform, + homePath: string, + executableNames: string[] +): string[] { + const directories = [ + join(homePath, '.volta', 'bin'), + join(homePath, '.asdf', 'shims'), + join(homePath, '.fnm', 'aliases', 'default', 'bin') + ] + + // Why: GUI-launched Electron apps do not inherit shell init from version + // managers like nvm, so `spawn('codex')` can fail for users who installed + // Codex under a Node-managed bin directory even though Terminal can run it. + // Probe the newest installed nvm version explicitly so rate-limit tracking + // and account login use the same Codex binary the shell would expose. + const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') + if (existsSync(nvmVersionsDir)) { + const nvmVersionDirectories = readdirSync(nvmVersionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareVersionDesc) + .map((entry) => join(nvmVersionsDir, entry, 'bin')) + + const firstNvmWithCodex = findFirstExecutable(nvmVersionDirectories, executableNames) + if (firstNvmWithCodex) { + directories.unshift(dirname(firstNvmWithCodex)) + } + } + + if (platform === 'win32') { + directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) + } else { + directories.push(join(homePath, '.local', 'bin')) + } + + return directories +} + +export function resolveCodexCommand(options: ResolveCodexCommandOptions = {}): string { + const platform = options.platform ?? process.platform + const executableNames = getExecutableNames(platform) + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null + const pathCandidate = findFirstExecutable(splitPath(pathEnv), executableNames) + if (pathCandidate) { + return pathCandidate + } + + const homePath = options.homePath ?? homedir() + const versionManagerCandidate = findFirstExecutable( + getVersionManagerDirectories(platform, homePath, executableNames), + executableNames + ) + return versionManagerCandidate ?? 'codex' +} diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index d1383b7fc..dc35c106a 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -3,6 +3,7 @@ paths together in one file makes it easier to audit the protocol/parsing differences and ensure account-scoped env handling stays identical. */ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { spawn } from 'node:child_process' +import { resolveCodexCommand } from '../codex-cli/command' const RPC_TIMEOUT_MS = 10_000 const PTY_TIMEOUT_MS = 15_000 @@ -84,16 +85,20 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { if (!resolved) { @@ -278,13 +283,14 @@ function parsePtyStatus(output: string): { async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { const pty = await import('node-pty') + const codexCommand = resolveCodexCommand() return new Promise((resolve) => { let output = '' let resolved = false let sentStatus = false - const term = pty.spawn('codex', [], { + const term = pty.spawn(codexCommand, [], { name: 'xterm-256color', cols: 120, rows: 40,