Fix Codex CLI detection for Electron tracking (#496)
This commit is contained in:
parent
db7eadf4e8
commit
49c7b788c3
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue