From 85f5d0a17448559dc137669c213e32618cb0c20e Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:28:04 +0300 Subject: [PATCH] fix: use consistent codex runtime --- eslint.config.js | 1 + .../list/codex/codex-auth.provider.ts | 9 +- .../services/shell-websocket.service.ts | 18 +- server/openai-codex.js | 9 +- server/shared/codex-cli-runtime.test.ts | 96 ++++++ server/shared/codex-cli-runtime.ts | 288 ++++++++++++++++++ 6 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 server/shared/codex-cli-runtime.test.ts create mode 100644 server/shared/codex-cli-runtime.ts diff --git a/eslint.config.js b/eslint.config.js index e002aece..dfe648e3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -161,6 +161,7 @@ export default tseslint.config( "server/shared/utils.{js,ts}", "server/shared/frontmatter.ts", "server/shared/claude-cli-path.ts", + "server/shared/codex-cli-runtime.ts", ], // classify shared utility files so modules can depend on them explicitly mode: "file", }, diff --git a/server/modules/providers/list/codex/codex-auth.provider.ts b/server/modules/providers/list/codex/codex-auth.provider.ts index e938e70d..64cc9120 100644 --- a/server/modules/providers/list/codex/codex-auth.provider.ts +++ b/server/modules/providers/list/codex/codex-auth.provider.ts @@ -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; } diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index d41b781f..3d64ba66 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -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', diff --git a/server/openai-codex.js b/server/openai-codex.js index 34f5bc05..84e6732e 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -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 = { diff --git a/server/shared/codex-cli-runtime.test.ts b/server/shared/codex-cli-runtime.test.ts new file mode 100644 index 00000000..c8bb3170 --- /dev/null +++ b/server/shared/codex-cli-runtime.test.ts @@ -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'"); +}); diff --git a/server/shared/codex-cli-runtime.ts b/server/shared/codex-cli-runtime.ts new file mode 100644 index 00000000..3bf6146c --- /dev/null +++ b/server/shared/codex-cli-runtime.ts @@ -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; + +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 +): 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 +): 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 +): 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 { + 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 = { + 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 { + 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, "'\\''")}'`; +}