Fix Codex CLI detection for Electron tracking (#496)

This commit is contained in:
Neil 2026-04-11 15:32:47 -07:00 committed by GitHub
parent db7eadf4e8
commit 49c7b788c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 182 additions and 11 deletions

View File

@ -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<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn('codex', ['login'], {
const child = spawn(resolveCodexCommand(), ['login'], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,

View File

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

View File

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

View File

@ -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<Provi
let resolved = false
let rpcId = 0
const child = spawn('codex', ['-s', 'read-only', '-a', 'untrusted', 'app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
env: {
...process.env,
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
const child = spawn(
resolveCodexCommand(),
['-s', 'read-only', '-a', 'untrusted', 'app-server'],
{
stdio: ['pipe', 'pipe', 'pipe'],
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
env: {
...process.env,
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
}
}
})
)
const timeout = setTimeout(() => {
if (!resolved) {
@ -278,13 +283,14 @@ function parsePtyStatus(output: string): {
async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
const pty = await import('node-pty')
const codexCommand = resolveCodexCommand()
return new Promise<ProviderRateLimits>((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,