fix: use consistent codex runtime

This commit is contained in:
Haileyesus
2026-06-15 14:28:04 +03:00
parent 9fb2d91b26
commit 85f5d0a174
6 changed files with 413 additions and 8 deletions

View File

@@ -6,6 +6,7 @@ import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from '@/shared/codex-cli-runtime.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type CodexCredentialsStatus = {
@@ -21,8 +22,12 @@ export class CodexProviderAuth implements IProviderAuth {
*/
private checkInstalled(): boolean {
try {
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
const result = spawn.sync(resolveCodexExecutablePath(), ['--version'], {
env: createCodexRuntimeEnv(),
stdio: 'ignore',
timeout: 5000,
});
return !result.error && result.status === 0;
} catch {
return false;
}

View File

@@ -5,6 +5,7 @@ import path from 'node:path';
import pty, { type IPty } from 'node-pty';
import { WebSocket, type RawData } from 'ws';
import { createCodexRuntimeEnv, getCodexShellCommand } from '@/shared/codex-cli-runtime.js';
import { parseIncomingJsonObject } from '@/shared/utils.js';
type ShellIncomingMessage = {
@@ -137,13 +138,14 @@ function buildShellCommand(
}
if (provider === 'codex') {
const codexCommand = getCodexShellCommand();
if (resumeSessionId) {
if (os.platform() === 'win32') {
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
return `${codexCommand} resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { ${codexCommand} }`;
}
return `codex resume "${resumeSessionId}" || codex`;
return `${codexCommand} resume "${resumeSessionId}" || ${codexCommand}`;
}
return 'codex';
return codexCommand;
}
if (provider === 'gemini') {
@@ -284,6 +286,14 @@ export function handleShellConnection(
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
const termCols = readNumber(data.cols, 80);
const termRows = readNumber(data.rows, 24);
const ptyEnv =
provider === 'codex'
? createCodexRuntimeEnv()
: Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] => entry[1] !== undefined
)
);
shellProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
@@ -291,7 +301,7 @@ export function handleShellConnection(
rows: termRows,
cwd: resolvedProjectPath,
env: {
...process.env,
...ptyEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',

View File

@@ -14,10 +14,12 @@
*/
import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from './shared/codex-cli-runtime.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Track active sessions
@@ -248,8 +250,11 @@ export async function queryCodex(command, options = {}, ws) {
const abortController = new AbortController();
try {
// Initialize Codex SDK
codex = new Codex();
// Initialize Codex SDK against the same user/global Codex runtime used by shell terminals.
codex = new Codex({
codexPathOverride: resolveCodexExecutablePath(),
env: createCodexRuntimeEnv(),
});
// Thread options with sandbox and approval settings
const threadOptions = {

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createCodexRuntimeEnv,
getCodexShellCommand,
resolveCodexExecutablePath,
type ResolveCodexExecutablePathDependencies,
} from '@/shared/codex-cli-runtime.js';
const POSIX_PATH_DELIMITER = ':';
function createExistsSync(paths: string[]): ResolveCodexExecutablePathDependencies['existsSync'] {
const existing = new Set(paths);
return ((candidate: string) => existing.has(candidate)) as ResolveCodexExecutablePathDependencies['existsSync'];
}
test('resolveCodexExecutablePath prefers the user npm-global install over app-local PATH entries', () => {
const globalCodexPath = '/home/devuser/.npm-global/bin/codex';
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
const resolved = resolveCodexExecutablePath(undefined, {
env: {
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
},
existsSync: createExistsSync([globalCodexPath, localCodexPath]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(resolved, globalCodexPath);
});
test('resolveCodexExecutablePath skips node_modules bin when a non-local PATH codex exists', () => {
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
const pathCodexPath = '/usr/local/bin/codex';
const resolved = resolveCodexExecutablePath(undefined, {
env: {
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/local/bin`,
},
existsSync: createExistsSync([localCodexPath, pathCodexPath]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(resolved, pathCodexPath);
});
test('resolveCodexExecutablePath falls back to app-local codex when it is the only install', () => {
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
const resolved = resolveCodexExecutablePath(undefined, {
env: {
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
},
existsSync: createExistsSync([localCodexPath]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(resolved, localCodexPath);
});
test('createCodexRuntimeEnv prepends the selected Codex directory to PATH', () => {
const runtimeEnv = createCodexRuntimeEnv(
{
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
},
{
existsSync: createExistsSync(['/home/devuser/.npm-global/bin/codex']),
homedir: () => '/home/devuser',
platform: 'linux',
}
);
assert.equal(
runtimeEnv.PATH,
`/home/devuser/.npm-global/bin${POSIX_PATH_DELIMITER}/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`
);
});
test('getCodexShellCommand quotes explicit executable paths for shell launches', () => {
const command = getCodexShellCommand({
env: {
CODEX_CLI_PATH: "/home/devuser/bin/codex with space",
},
existsSync: createExistsSync([]),
homedir: () => '/home/devuser',
platform: 'linux',
});
assert.equal(command, "'/home/devuser/bin/codex with space'");
});

View File

@@ -0,0 +1,288 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const DEFAULT_CODEX_COMMAND = 'codex';
const CODEX_CLI_PATH_ENV_KEYS = ['CODEX_CLI_PATH', 'CLOUDCLI_CODEX_CLI_PATH'] as const;
/**
* Codex runtime precedence:
* 1. Explicit CODEX_CLI_PATH or CLOUDCLI_CODEX_CLI_PATH.
* 2. User/global installs such as NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, or ~/.local/bin.
* 3. Non-local PATH entries.
* 4. App-local node_modules/.bin as the final fallback.
*/
type EnvRecord = Record<string, string | undefined>;
export type ResolveCodexExecutablePathDependencies = {
env?: EnvRecord;
existsSync?: typeof fs.existsSync;
homedir?: typeof os.homedir;
platform?: NodeJS.Platform;
};
/**
* Returns the path implementation that matches the target runtime platform.
*/
function getPathApi(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path.posix;
}
/**
* Returns the PATH delimiter used by the target runtime platform.
*/
function getPathDelimiter(platform: NodeJS.Platform): string {
return platform === 'win32' ? ';' : ':';
}
/**
* Removes one matching pair of surrounding quotes from a configured path value.
*/
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
/**
* Checks whether a command value looks like a filesystem path instead of a bare command name.
*/
function isPathLike(value: string, platform: NodeJS.Platform): boolean {
return value.includes('/') || value.includes('\\') || getPathApi(platform).isAbsolute(value);
}
/**
* Finds the environment key that represents PATH, preserving Windows case variants.
*/
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
if (platform !== 'win32') {
return 'PATH';
}
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
}
/**
* Returns the Codex executable filenames to probe for the target platform.
*/
function getExecutableNames(platform: NodeJS.Platform): string[] {
if (platform !== 'win32') {
return [DEFAULT_CODEX_COMMAND];
}
return ['codex.exe', 'codex.cmd', 'codex.bat', 'codex.ps1', DEFAULT_CODEX_COMMAND];
}
/**
* Deduplicates non-empty string values while preserving their original order.
*/
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
/**
* Detects app-local npm bin directories so they can be treated as a fallback.
*/
function isNodeModulesBinPath(directoryPath: string, platform: NodeJS.Platform): boolean {
const pathApi = getPathApi(platform);
const normalized = directoryPath.replace(/[\\/]+$/, '');
return (
pathApi.basename(normalized).toLowerCase() === '.bin' &&
pathApi.basename(pathApi.dirname(normalized)).toLowerCase() === 'node_modules'
);
}
/**
* Resolves the first Codex executable that exists inside one directory.
*/
function resolveExecutableInDirectory(
directoryPath: string,
deps: Required<ResolveCodexExecutablePathDependencies>
): string | null {
const pathApi = getPathApi(deps.platform);
for (const executableName of getExecutableNames(deps.platform)) {
const candidate = pathApi.join(directoryPath, executableName);
if (deps.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Reads an explicit Codex executable override from supported environment variables.
*/
function getConfiguredCodexPath(env: EnvRecord): string | null {
for (const key of CODEX_CLI_PATH_ENV_KEYS) {
const value = env[key]?.trim();
if (value) {
return stripWrappingQuotes(value);
}
}
return null;
}
/**
* Builds user/global Codex install candidates that rank ahead of PATH and app-local installs:
* NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, ~/.local/bin, or the Windows npm user folders.
*/
function getPreferredUserInstallCandidates(
deps: Required<ResolveCodexExecutablePathDependencies>
): string[] {
const pathApi = getPathApi(deps.platform);
const homeDir = deps.homedir();
const candidates: string[] = [];
const npmPrefix = deps.env.NPM_CONFIG_PREFIX?.trim();
if (npmPrefix) {
candidates.push(pathApi.join(npmPrefix, deps.platform === 'win32' ? '' : 'bin'));
}
if (deps.platform === 'win32') {
const appData = deps.env.APPDATA?.trim();
if (appData) {
candidates.push(appData, pathApi.join(appData, 'npm'));
}
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
} else {
candidates.push(
pathApi.join(homeDir, '.npm-global', 'bin'),
pathApi.join(homeDir, '.local', 'bin'),
);
}
return unique(candidates);
}
/**
* Searches PATH for Codex after user/global candidates, keeping node_modules/.bin as the last fallback.
*/
function resolveFromPath(
deps: Required<ResolveCodexExecutablePathDependencies>
): string | null {
const pathKey = getPathEnvKey(deps.env, deps.platform);
const pathValue = deps.env[pathKey] ?? '';
const directories = unique(pathValue.split(getPathDelimiter(deps.platform)).filter(Boolean));
let nodeModulesFallback: string | null = null;
for (const directory of directories) {
const candidate = resolveExecutableInDirectory(directory, deps);
if (!candidate) {
continue;
}
if (isNodeModulesBinPath(directory, deps.platform)) {
nodeModulesFallback ??= candidate;
continue;
}
return candidate;
}
return nodeModulesFallback;
}
/**
* Converts a process-style environment object into a string-only environment for child processes.
*/
function toStringEnv(env: EnvRecord): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
);
}
/**
* Resolves the Codex executable path for all backend entry points in this order:
* explicit CODEX_CLI_PATH/CLOUDCLI_CODEX_CLI_PATH, user/global installs, non-local PATH, app-local PATH.
*/
export function resolveCodexExecutablePath(
configuredPath: string | undefined = undefined,
dependencies: ResolveCodexExecutablePathDependencies = {}
): string {
const deps: Required<ResolveCodexExecutablePathDependencies> = {
env: dependencies.env ?? process.env,
existsSync: dependencies.existsSync ?? fs.existsSync,
homedir: dependencies.homedir ?? os.homedir,
platform: dependencies.platform ?? process.platform,
};
const normalizedConfiguredPath = stripWrappingQuotes(
configuredPath?.trim() || getConfiguredCodexPath(deps.env) || ''
);
if (normalizedConfiguredPath) {
if (!isPathLike(normalizedConfiguredPath, deps.platform)) {
return resolveFromPath(deps) ?? normalizedConfiguredPath;
}
return normalizedConfiguredPath;
}
for (const candidateDirectory of getPreferredUserInstallCandidates(deps)) {
const candidate = resolveExecutableInDirectory(candidateDirectory, deps);
if (candidate) {
return candidate;
}
}
return resolveFromPath(deps) ?? DEFAULT_CODEX_COMMAND;
}
/**
* Creates a Codex child-process environment with the selected runtime directory first on PATH,
* preserving the same source precedence as resolveCodexExecutablePath.
*/
export function createCodexRuntimeEnv(
env: EnvRecord = process.env,
dependencies: ResolveCodexExecutablePathDependencies = {}
): Record<string, string> {
const platform = dependencies.platform ?? process.platform;
const pathApi = getPathApi(platform);
const resolvedCodexPath = resolveCodexExecutablePath(undefined, {
...dependencies,
env,
platform,
});
const pathKey = getPathEnvKey(env, platform);
const currentPath = env[pathKey] ?? '';
const resolvedDirectory = isPathLike(resolvedCodexPath, platform)
? pathApi.dirname(resolvedCodexPath)
: '';
const nextEnv: EnvRecord = { ...env };
if (resolvedDirectory) {
const delimiter = getPathDelimiter(platform);
const pathEntries = currentPath.split(delimiter).filter(Boolean);
if (!pathEntries.includes(resolvedDirectory)) {
nextEnv[pathKey] = currentPath
? `${resolvedDirectory}${delimiter}${currentPath}`
: resolvedDirectory;
}
}
return toStringEnv(nextEnv);
}
/**
* Returns the shell-safe Codex command used by interactive PTY launches.
*/
export function getCodexShellCommand(
dependencies: ResolveCodexExecutablePathDependencies = {}
): string {
const platform = dependencies.platform ?? process.platform;
const resolvedCodexPath = resolveCodexExecutablePath(undefined, dependencies);
if (!isPathLike(resolvedCodexPath, platform)) {
return resolvedCodexPath;
}
if (platform === 'win32') {
return `& '${resolvedCodexPath.replace(/'/g, "''")}'`;
}
return `'${resolvedCodexPath.replace(/'/g, "'\\''")}'`;
}