Files
ccs/src/targets/codex-adapter.ts
T

472 lines
15 KiB
TypeScript

import { ChildProcess, spawn } from 'child_process';
import * as fs from 'fs';
import type { ProfileType } from '../types/profile';
import { runCleanup } from '../errors';
import { expandPath } from '../utils/helpers';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripBrowserEnv,
stripAnthropicEnv,
stripCodexSessionEnv,
} from '../utils/shell-executor';
import type {
TargetAdapter,
TargetBinaryInfo,
TargetCredentials,
TargetType,
} from './target-adapter';
import {
codexBinarySupportsConfigOverrides,
detectCodexCli,
getCodexBinaryInfo,
readCodexVersion,
} from './codex-detector';
import { createLogger } from '../services/logging';
import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager';
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
import { getModelMaxLevel } from '../cliproxy/model-catalog';
import { parseCodexModelTuningAlias } from '../cliproxy/ai-providers/model-id-normalizer';
import {
CCSXP_CLIPROXY_SHORTCUT_ENV,
CODEX_CLIPROXY_PROVIDER_ENV_KEY,
ensureCodexCliproxyProviderConfig,
isCcsxpCliproxyShortcut,
} from './codex-cliproxy-provider-config';
const adapterLogger = createLogger('targets:codex');
const CODEX_RUNTIME_PROVIDER_ID = 'ccs_runtime';
const CODEX_RUNTIME_ENV_KEY = 'CCS_CODEX_API_KEY';
const CODEX_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
const CODEX_INFO_FLAGS = new Set(['--help', '-h', '--version', '-v']);
const CODEX_FAST_SERVICE_TIER = 'priority';
function formatTomlString(value: string): string {
return JSON.stringify(value);
}
function buildConfigOverrideArgs(overrides: string[]): string[] {
return overrides.flatMap((override) => ['-c', override]);
}
function buildConfigOverrideSupportError(binaryInfo?: TargetBinaryInfo): Error {
const versionSummary = binaryInfo?.version ? ` (${binaryInfo.version})` : '';
return new Error(
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles or runtime reasoning overrides.`
);
}
function hydrateCodexBinaryVersion(binaryInfo?: TargetBinaryInfo): TargetBinaryInfo | undefined {
if (!binaryInfo || binaryInfo.version || !binaryInfo.path) {
return binaryInfo;
}
return {
...binaryInfo,
version: readCodexVersion(binaryInfo.path),
};
}
function findDisallowedCodexManagedFlags(args: string[]): string[] {
const disallowed = new Set<string>();
for (const arg of args) {
if (arg === '-c' || arg === '--config' || arg.startsWith('--config=')) {
disallowed.add('--config/-c');
continue;
}
if (arg === '-p' || arg === '--profile' || arg.startsWith('--profile=')) {
disallowed.add('--profile/-p');
continue;
}
if (arg === '--oss') {
disallowed.add('--oss');
continue;
}
if (arg === '--local-provider' || arg.startsWith('--local-provider=')) {
disallowed.add('--local-provider');
}
}
return [...disallowed];
}
function normalizeCodexReasoningOverride(value: string | number | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value === 'string' && CODEX_REASONING_LEVELS.has(value)) {
return value;
}
throw new Error(
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.'
);
}
function normalizeCodexModelFlagValue(value: string): {
model: string;
effort?: string;
serviceTier?: string;
} | null {
const parsed = parseCodexModelTuningAlias(value);
if (!parsed || !parsed.baseModel) return null;
if (getModelMaxLevel('codex', parsed.baseModel) === undefined) return null;
return {
model: parsed.baseModel,
effort: parsed.effort ?? undefined,
serviceTier: parsed.serviceTier ? CODEX_FAST_SERVICE_TIER : undefined,
};
}
function normalizeCcsxpCodexModelFlagAliases(args: string[]): {
args: string[];
overrides: string[];
} {
const normalizedArgs = [...args];
let reasoningEffort: string | undefined;
let serviceTier: string | undefined;
const applyModelValue = (value: string): string => {
const normalized = normalizeCodexModelFlagValue(value);
if (!normalized) {
reasoningEffort = undefined;
serviceTier = undefined;
return value;
}
reasoningEffort = normalized.effort;
serviceTier = normalized.serviceTier;
return normalized.model;
};
for (let index = 0; index < normalizedArgs.length; index += 1) {
const arg = normalizedArgs[index];
if (arg === '-m' || arg === '--model') {
const nextValue = normalizedArgs[index + 1];
if (typeof nextValue === 'string') {
normalizedArgs[index + 1] = applyModelValue(nextValue);
index += 1;
}
continue;
}
for (const prefix of ['--model=', '-m=']) {
if (arg.startsWith(prefix)) {
normalizedArgs[index] = `${prefix}${applyModelValue(arg.slice(prefix.length))}`;
break;
}
}
}
const overrides: string[] = [];
if (reasoningEffort) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningEffort)}`);
}
if (serviceTier) {
overrides.push(`service_tier=${formatTomlString(serviceTier)}`);
}
return { args: normalizedArgs, overrides };
}
function isInformationalCodexInvocation(args: string[]): boolean {
if (args.length === 1) {
return CODEX_INFO_FLAGS.has(args[0] || '');
}
if (args.length === 2) {
return CODEX_INFO_FLAGS.has(args[1] || '');
}
return false;
}
function normalizeExplicitCodexHomeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const rawCodexHome = env.CODEX_HOME;
if (rawCodexHome === undefined) {
return env;
}
const trimmedCodexHome = rawCodexHome.trim();
if (!trimmedCodexHome) {
const nextEnv = { ...env };
delete nextEnv.CODEX_HOME;
return nextEnv;
}
const normalizedCodexHome = expandPath(trimmedCodexHome);
if (normalizedCodexHome === rawCodexHome) {
return env;
}
return {
...env,
CODEX_HOME: normalizedCodexHome,
};
}
function prepareExplicitCodexHome(
env: NodeJS.ProcessEnv,
args: string[]
): { env: NodeJS.ProcessEnv; error?: string } {
const normalizedEnv = normalizeExplicitCodexHomeEnv(env);
const codexHome = normalizedEnv.CODEX_HOME;
if (!codexHome) {
return { env: normalizedEnv };
}
if (isInformationalCodexInvocation(args)) {
return { env: normalizedEnv };
}
try {
fs.mkdirSync(codexHome, { recursive: true });
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'EEXIST') {
return {
env: normalizedEnv,
error: `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`,
};
}
}
try {
if (!fs.statSync(codexHome).isDirectory()) {
return {
env: normalizedEnv,
error: `[X] CODEX_HOME path is not a directory: ${codexHome}`,
};
}
return { env: normalizedEnv };
} catch (err) {
const error = err as NodeJS.ErrnoException;
return {
env: normalizedEnv,
error: `[X] Unable to access CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`,
};
}
}
export class CodexAdapter implements TargetAdapter {
readonly type: TargetType = 'codex';
readonly displayName = 'Codex CLI';
private ccsxpCliproxyEnvKey = CODEX_CLIPROXY_PROVIDER_ENV_KEY;
detectBinary(): TargetBinaryInfo | null {
return getCodexBinaryInfo({ includeVersion: false, includeFeatures: false });
}
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
if (!isCcsxpCliproxyShortcut()) {
return;
}
try {
const providerRepair = await ensureCodexCliproxyProviderConfig(resolveLifecyclePort());
this.ccsxpCliproxyEnvKey = providerRepair.envKey;
} catch (error) {
throw new Error(
`ccsxp could not repair the native Codex cliproxy provider: ${(error as Error).message}`
);
}
}
buildArgs(
_profile: string,
userArgs: string[],
options?: {
creds?: TargetCredentials;
profileType?: ProfileType;
binaryInfo?: TargetBinaryInfo;
}
): string[] {
const profileType = options?.profileType || 'default';
const creds = options?.creds;
const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride);
const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? [];
if (profileType === 'default') {
const isCcsxpShortcut = isCcsxpCliproxyShortcut();
const modelFlagNormalization = isCcsxpShortcut
? normalizeCcsxpCodexModelFlagAliases(userArgs)
: { args: userArgs, overrides: [] };
const overrides = [...runtimeConfigOverrides, ...modelFlagNormalization.overrides];
if (reasoningOverride) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
const needsConfigOverrideSupport = isCcsxpShortcut || overrides.length > 0;
if (needsConfigOverrideSupport && !codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo));
}
if (overrides.length === 0) {
return modelFlagNormalization.args;
}
return [...buildConfigOverrideArgs(overrides), ...modelFlagNormalization.args];
}
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo));
}
if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) {
throw new Error(
'Codex target requires base URL and API key for CCS-backed profile launches.'
);
}
const disallowedFlags = findDisallowedCodexManagedFlags(userArgs);
if (disallowedFlags.length > 0) {
throw new Error(
`Codex target does not allow ${disallowedFlags.join(', ')} when CCS manages the runtime provider. Remove native Codex provider selection flags and retry.`
);
}
const overrides = [
`model_provider=${formatTomlString(CODEX_RUNTIME_PROVIDER_ID)}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.name=${formatTomlString('CCS Runtime')}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString(creds.baseUrl)}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.env_key=${formatTomlString(CODEX_RUNTIME_ENV_KEY)}`,
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.wire_api=${formatTomlString('responses')}`,
];
if (creds.model?.trim()) {
overrides.push(`model=${formatTomlString(creds.model)}`);
}
overrides.push(...runtimeConfigOverrides);
if (reasoningOverride) {
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
}
return [...buildConfigOverrideArgs(overrides), ...userArgs];
}
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))),
};
delete env[CCSXP_CLIPROXY_SHORTCUT_ENV];
delete env[CODEX_RUNTIME_ENV_KEY];
if (profileType === 'default' && isCcsxpCliproxyShortcut()) {
env[this.ccsxpCliproxyEnvKey || CODEX_CLIPROXY_PROVIDER_ENV_KEY] = getEffectiveApiKey();
}
if (profileType !== 'default') {
if (!creds.apiKey?.trim()) {
throw new Error('Codex target requires an API key for CCS-backed profile launches.');
}
env[CODEX_RUNTIME_ENV_KEY] = creds.apiKey;
}
return env;
}
exec(
args: string[],
env: NodeJS.ProcessEnv,
options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
): void {
const exitWithCleanup = (code: number): never => {
try {
runCleanup();
} catch {
// Cleanup is best-effort on launch errors.
}
process.exit(code);
};
const codexPath = options?.binaryInfo?.path || detectCodexCli();
if (!codexPath) {
console.error('[X] Codex CLI not found. Install a recent @openai/codex build first.');
return exitWithCleanup(1);
}
try {
const stat = fs.statSync(codexPath);
if (!stat.isFile()) {
console.error(`[X] Codex CLI path is not a file: ${codexPath}`);
return exitWithCleanup(1);
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
console.error(
`[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}`
);
return exitWithCleanup(1);
}
const codexHomePreparation = prepareExplicitCodexHome(env, args);
if (codexHomePreparation.error) {
console.error(codexHomePreparation.error);
return exitWithCleanup(1);
}
const launchEnv = codexHomePreparation.env;
const isWindows = process.platform === 'win32';
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
const spawnStartedAt = Date.now();
adapterLogger.stage('dispatch', 'target.spawn', 'Spawning Codex CLI child process', {
target: 'codex',
codexPath,
argCount: args.length,
});
let child: ChildProcess;
if (isPowerShellScript) {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args],
{ stdio: 'inherit', windowsHide: true, env: launchEnv }
);
} else if (needsShell) {
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: getWindowsEscapedCommandShell(),
env: launchEnv,
});
} else {
child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env: launchEnv });
}
child.on('exit', (code, signal) => {
adapterLogger.stage(
'respond',
'target.exit',
'Codex CLI child process exited',
{ target: 'codex', exitCode: code, signal },
{ latencyMs: Date.now() - spawnStartedAt }
);
});
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') {
console.error(`[X] Codex CLI is not executable: ${codexPath}`);
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
} else if (needsShell) {
console.error('[X] Windows command shell not found for Codex wrapper launch.');
} else {
console.error(`[X] Codex CLI not found: ${codexPath}`);
}
} else {
console.error(`[X] Failed to start Codex CLI (${codexPath}): ${err.message}`);
}
return exitWithCleanup(1);
});
}
supportsProfileType(profileType: ProfileType): boolean {
// Bridge-backed settings profiles need additional compatibility context that the
// adapter contract does not receive, so keep the adapter-level claim conservative.
return profileType === 'default' || profileType === 'cliproxy';
}
}