mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-17 22:01:57 +08:00
Compare commits
2 Commits
main
...
fix/codex-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c79b8fdfb | ||
|
|
85f5d0a174 |
@@ -161,6 +161,8 @@ export default tseslint.config(
|
|||||||
"server/shared/utils.{js,ts}",
|
"server/shared/utils.{js,ts}",
|
||||||
"server/shared/frontmatter.ts",
|
"server/shared/frontmatter.ts",
|
||||||
"server/shared/claude-cli-path.ts",
|
"server/shared/claude-cli-path.ts",
|
||||||
|
"server/shared/cli-runtime-env.ts",
|
||||||
|
"server/shared/codex-cli-runtime.ts",
|
||||||
], // classify shared utility files so modules can depend on them explicitly
|
], // classify shared utility files so modules can depend on them explicitly
|
||||||
mode: "file",
|
mode: "file",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import spawn from 'cross-spawn';
|
|||||||
|
|
||||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||||
|
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from '@/shared/codex-cli-runtime.js';
|
||||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||||
|
|
||||||
type CodexCredentialsStatus = {
|
type CodexCredentialsStatus = {
|
||||||
@@ -21,8 +22,12 @@ export class CodexProviderAuth implements IProviderAuth {
|
|||||||
*/
|
*/
|
||||||
private checkInstalled(): boolean {
|
private checkInstalled(): boolean {
|
||||||
try {
|
try {
|
||||||
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
const result = spawn.sync(resolveCodexExecutablePath(), ['--version'], {
|
||||||
return true;
|
env: createCodexRuntimeEnv(),
|
||||||
|
stdio: 'ignore',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
return !result.error && result.status === 0;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import path from 'node:path';
|
|||||||
import pty, { type IPty } from 'node-pty';
|
import pty, { type IPty } from 'node-pty';
|
||||||
import { WebSocket, type RawData } from 'ws';
|
import { WebSocket, type RawData } from 'ws';
|
||||||
|
|
||||||
|
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
|
||||||
|
import { getCodexShellCommand } from '@/shared/codex-cli-runtime.js';
|
||||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||||
|
|
||||||
type ShellIncomingMessage = {
|
type ShellIncomingMessage = {
|
||||||
@@ -137,13 +139,14 @@ function buildShellCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'codex') {
|
if (provider === 'codex') {
|
||||||
|
const codexCommand = getCodexShellCommand();
|
||||||
if (resumeSessionId) {
|
if (resumeSessionId) {
|
||||||
if (os.platform() === 'win32') {
|
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') {
|
if (provider === 'gemini') {
|
||||||
@@ -284,6 +287,10 @@ export function handleShellConnection(
|
|||||||
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||||
const termCols = readNumber(data.cols, 80);
|
const termCols = readNumber(data.cols, 80);
|
||||||
const termRows = readNumber(data.rows, 24);
|
const termRows = readNumber(data.rows, 24);
|
||||||
|
// Plain terminals inherit the server process PATH, which npm can prefix with
|
||||||
|
// /opt/claudecodeui/node_modules/.bin. Put user CLI bins first so shell
|
||||||
|
// commands resolve like the user's login shell instead of the app install.
|
||||||
|
const ptyEnv = createUserShellRuntimeEnv();
|
||||||
|
|
||||||
shellProcess = pty.spawn(shell, shellArgs, {
|
shellProcess = pty.spawn(shell, shellArgs, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
@@ -291,7 +298,7 @@ export function handleShellConnection(
|
|||||||
rows: termRows,
|
rows: termRows,
|
||||||
cwd: resolvedProjectPath,
|
cwd: resolvedProjectPath,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...ptyEnv,
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
COLORTERM: 'truecolor',
|
COLORTERM: 'truecolor',
|
||||||
FORCE_COLOR: '3',
|
FORCE_COLOR: '3',
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Codex } from '@openai/codex-sdk';
|
import { Codex } from '@openai/codex-sdk';
|
||||||
|
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.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';
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
// Track active sessions
|
// Track active sessions
|
||||||
@@ -248,8 +250,11 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize Codex SDK
|
// Initialize Codex SDK against the same user/global Codex runtime used by shell terminals.
|
||||||
codex = new Codex();
|
codex = new Codex({
|
||||||
|
codexPathOverride: resolveCodexExecutablePath(),
|
||||||
|
env: createCodexRuntimeEnv(),
|
||||||
|
});
|
||||||
|
|
||||||
// Thread options with sandbox and approval settings
|
// Thread options with sandbox and approval settings
|
||||||
const threadOptions = {
|
const threadOptions = {
|
||||||
|
|||||||
55
server/shared/cli-runtime-env.test.ts
Normal file
55
server/shared/cli-runtime-env.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
|
||||||
|
|
||||||
|
const POSIX_PATH_DELIMITER = ':';
|
||||||
|
|
||||||
|
test('createUserShellRuntimeEnv prepends user CLI bins before app-local npm bins', () => {
|
||||||
|
const runtimeEnv = createUserShellRuntimeEnv(
|
||||||
|
{
|
||||||
|
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
|
||||||
|
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
homedir: () => '/home/devuser',
|
||||||
|
platform: 'linux',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
runtimeEnv.PATH,
|
||||||
|
[
|
||||||
|
'/home/devuser/.npm-global/bin',
|
||||||
|
'/home/devuser/.local/bin',
|
||||||
|
'/opt/claudecodeui/node_modules/.bin',
|
||||||
|
'/usr/bin',
|
||||||
|
].join(POSIX_PATH_DELIMITER)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createUserShellRuntimeEnv does not duplicate existing user CLI path entries', () => {
|
||||||
|
const runtimeEnv = createUserShellRuntimeEnv(
|
||||||
|
{
|
||||||
|
PATH: [
|
||||||
|
'/home/devuser/.npm-global/bin',
|
||||||
|
'/opt/claudecodeui/node_modules/.bin',
|
||||||
|
'/usr/bin',
|
||||||
|
].join(POSIX_PATH_DELIMITER),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
homedir: () => '/home/devuser',
|
||||||
|
platform: 'linux',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
runtimeEnv.PATH,
|
||||||
|
[
|
||||||
|
'/home/devuser/.local/bin',
|
||||||
|
'/home/devuser/.npm-global/bin',
|
||||||
|
'/opt/claudecodeui/node_modules/.bin',
|
||||||
|
'/usr/bin',
|
||||||
|
].join(POSIX_PATH_DELIMITER)
|
||||||
|
);
|
||||||
|
});
|
||||||
109
server/shared/cli-runtime-env.ts
Normal file
109
server/shared/cli-runtime-env.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export type EnvRecord = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export type CliRuntimeEnvDependencies = {
|
||||||
|
env?: EnvRecord;
|
||||||
|
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' ? ';' : ':';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates non-empty string values while preserving their original order.
|
||||||
|
*/
|
||||||
|
function unique(values: string[]): string[] {
|
||||||
|
return Array.from(new Set(values.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a process-style environment object into a string-only environment for child processes.
|
||||||
|
*/
|
||||||
|
export function toStringEnv(env: EnvRecord): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds user/global CLI bin directories that should rank ahead of app-local npm bins.
|
||||||
|
*/
|
||||||
|
export function getPreferredUserCliBinDirectories(
|
||||||
|
dependencies: Required<CliRuntimeEnvDependencies>
|
||||||
|
): string[] {
|
||||||
|
const pathApi = getPathApi(dependencies.platform);
|
||||||
|
const homeDir = dependencies.homedir();
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const npmPrefix = dependencies.env.NPM_CONFIG_PREFIX?.trim();
|
||||||
|
|
||||||
|
if (npmPrefix) {
|
||||||
|
candidates.push(pathApi.join(npmPrefix, dependencies.platform === 'win32' ? '' : 'bin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dependencies.platform === 'win32') {
|
||||||
|
const appData = dependencies.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a provider-neutral shell environment that prefers user/global CLI bins over app-local bins.
|
||||||
|
*/
|
||||||
|
export function createUserShellRuntimeEnv(
|
||||||
|
env: EnvRecord = process.env,
|
||||||
|
dependencies: CliRuntimeEnvDependencies = {}
|
||||||
|
): Record<string, string> {
|
||||||
|
const deps: Required<CliRuntimeEnvDependencies> = {
|
||||||
|
env,
|
||||||
|
homedir: dependencies.homedir ?? os.homedir,
|
||||||
|
platform: dependencies.platform ?? process.platform,
|
||||||
|
};
|
||||||
|
const pathKey = getPathEnvKey(env, deps.platform);
|
||||||
|
const delimiter = getPathDelimiter(deps.platform);
|
||||||
|
const currentPathEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean);
|
||||||
|
const preferredEntries = getPreferredUserCliBinDirectories(deps).filter(
|
||||||
|
(entry) => !currentPathEntries.includes(entry)
|
||||||
|
);
|
||||||
|
const nextEnv: EnvRecord = { ...env };
|
||||||
|
|
||||||
|
if (preferredEntries.length > 0) {
|
||||||
|
nextEnv[pathKey] = [...preferredEntries, ...currentPathEntries].join(delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toStringEnv(nextEnv);
|
||||||
|
}
|
||||||
96
server/shared/codex-cli-runtime.test.ts
Normal file
96
server/shared/codex-cli-runtime.test.ts
Normal 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'");
|
||||||
|
});
|
||||||
288
server/shared/codex-cli-runtime.ts
Normal file
288
server/shared/codex-cli-runtime.ts
Normal 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, "'\\''")}'`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user