diff --git a/src/ccs.ts b/src/ccs.ts index 6b412371..9ae26160 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -95,6 +95,7 @@ interface RuntimeReasoningResolution { } const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); +const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); /** * Smart profile detection @@ -239,6 +240,47 @@ function resolveNativeClaudeLaunchArgs( return buildOfficialChannelsArgs(args, plan.appliedChannels, plan.wantsPermissionBypass); } +function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean { + const targetArgs = stripTargetFlag(args); + if (targetArgs.length === 0) { + return false; + } + + return ( + resolveTargetType(args) === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(targetArgs[0] || '') + ); +} + +function execNativeCodexFlagCommand(args: string[]): void { + const adapter = getTarget('codex'); + if (!adapter) { + console.error(fail('Target adapter not found for "codex"')); + process.exit(1); + } + + const binaryInfo = adapter.detectBinary(); + if (!binaryInfo) { + console.error(fail('Codex CLI not found.')); + console.error(info('Install a recent @openai/codex build, then retry.')); + process.exit(1); + } + + const targetArgs = stripTargetFlag(args); + const creds: TargetCredentials = { + profile: 'default', + baseUrl: '', + apiKey: '', + }; + + const builtArgs = adapter.buildArgs('default', targetArgs, { + creds, + profileType: 'default', + binaryInfo, + }); + const targetEnv = adapter.buildEnv(creds, 'default'); + adapter.exec(builtArgs, targetEnv, { binaryInfo }); +} + async function main(): Promise { // Register target adapters registerTarget(new ClaudeAdapter()); @@ -315,6 +357,11 @@ async function main(): Promise { } } + if (shouldPassthroughNativeCodexFlagCommand(args)) { + execNativeCodexFlagCommand(args); + return; + } + const firstArg = args[0]; // Trigger update check early for ALL commands except version/help/update diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 859ec7d3..b2e6f774 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import type { ProfileType } from '../types/profile'; import { runCleanup } from '../errors'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; -import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor'; +import { escapeShellArg, stripAnthropicEnv, stripCodexSessionEnv } from '../utils/shell-executor'; import type { TargetAdapter, TargetBinaryInfo, @@ -76,7 +76,7 @@ export class CodexAdapter implements TargetAdapter { readonly displayName = 'Codex CLI'; detectBinary(): TargetBinaryInfo | null { - return getCodexBinaryInfo(); + return getCodexBinaryInfo({ includeVersion: false, includeFeatures: false }); } async prepareCredentials(_creds: TargetCredentials): Promise { @@ -148,7 +148,9 @@ export class CodexAdapter implements TargetAdapter { } buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...stripAnthropicEnv(process.env) }; + const env: NodeJS.ProcessEnv = { + ...stripCodexSessionEnv(stripAnthropicEnv(process.env)), + }; delete env[CODEX_RUNTIME_ENV_KEY]; if (profileType !== 'default') { if (!creds.apiKey?.trim()) { diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index 07b1a7a5..56cfb79b 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -118,7 +118,10 @@ export function detectCodexCli(): string | null { return null; } -export function getCodexBinaryInfo(): TargetBinaryInfo | null { +export function getCodexBinaryInfo(options?: { + includeVersion?: boolean; + includeFeatures?: boolean; +}): TargetBinaryInfo | null { const codexPath = detectCodexCli(); if (!codexPath) return null; @@ -126,13 +129,21 @@ export function getCodexBinaryInfo(): TargetBinaryInfo | null { return { path: codexPath, needsShell: isWindows && /\.(cmd|bat|ps1)$/i.test(codexPath), - version: readCodexVersion(codexPath), - features: detectCodexFeatures(codexPath), + version: options?.includeVersion === false ? undefined : readCodexVersion(codexPath), + features: options?.includeFeatures === false ? undefined : detectCodexFeatures(codexPath), }; } export function codexBinarySupportsConfigOverrides( binaryInfo: TargetBinaryInfo | null | undefined ): boolean { - return Boolean(binaryInfo?.features?.includes(CODEX_CONFIG_OVERRIDE_FEATURE)); + if (!binaryInfo) { + return false; + } + + if (binaryInfo.features) { + return binaryInfo.features.includes(CODEX_CONFIG_OVERRIDE_FEATURE); + } + + return detectCodexFeatures(binaryInfo.path).includes(CODEX_CONFIG_OVERRIDE_FEATURE); } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index ff96458e..6d90c428 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -42,6 +42,25 @@ export function stripClaudeCodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +/** + * Strip Codex session-scoped env vars before launching a nested Codex process. + * + * Keep real user config such as CODEX_HOME intact. Only remove the known + * session/runtime metadata exported by the current Codex host process. + */ +export function stripCodexSessionEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const sessionKeys = new Set(['CODEX_CI', 'CODEX_MANAGED_BY_BUN', 'CODEX_THREAD_ID']); + const result: NodeJS.ProcessEnv = {}; + for (const key of Object.keys(env)) { + const upperKey = key.toUpperCase(); + if (sessionKeys.has(upperKey)) { + continue; + } + result[key] = env[key]; + } + return result; +} + /** * Resolve CCS-managed environment overrides for Claude launch. * - preferences.auto_update: false -> DISABLE_AUTOUPDATER=1 diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 57214f77..0feaa191 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -165,24 +165,65 @@ describe('CodexAdapter', () => { }); test('injects CCS_CODEX_API_KEY for CCS-backed launches only', () => { - const settingsEnv = adapter.buildEnv( - { - profile: 'codex', - baseUrl: 'http://127.0.0.1:8317/api/provider/codex', - apiKey: 'cliproxy-token', - }, - 'cliproxy' - ); - expect(settingsEnv.CCS_CODEX_API_KEY).toBe('cliproxy-token'); + const originalCodeXHome = process.env.CODEX_HOME; + const originalCodeXCi = process.env.CODEX_CI; + const originalCodeXThreadId = process.env.CODEX_THREAD_ID; + const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; - const defaultEnv = adapter.buildEnv( - { - profile: 'default', - baseUrl: '', - apiKey: '', - }, - 'default' - ); - expect(defaultEnv.CCS_CODEX_API_KEY).toBeUndefined(); + try { + process.env.CODEX_HOME = '/tmp/codex-home'; + process.env.CODEX_CI = '1'; + process.env.CODEX_THREAD_ID = 'thread-123'; + process.env.ANTHROPIC_BASE_URL = 'https://stale-proxy.invalid'; + + const settingsEnv = adapter.buildEnv( + { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + }, + 'cliproxy' + ); + expect(settingsEnv.CCS_CODEX_API_KEY).toBe('cliproxy-token'); + expect(settingsEnv.CODEX_HOME).toBe('/tmp/codex-home'); + expect(settingsEnv.CODEX_CI).toBeUndefined(); + expect(settingsEnv.CODEX_THREAD_ID).toBeUndefined(); + expect(settingsEnv.ANTHROPIC_BASE_URL).toBeUndefined(); + + const defaultEnv = adapter.buildEnv( + { + profile: 'default', + baseUrl: '', + apiKey: '', + }, + 'default' + ); + expect(defaultEnv.CCS_CODEX_API_KEY).toBeUndefined(); + expect(defaultEnv.CODEX_HOME).toBe('/tmp/codex-home'); + expect(defaultEnv.CODEX_CI).toBeUndefined(); + expect(defaultEnv.CODEX_THREAD_ID).toBeUndefined(); + expect(defaultEnv.ANTHROPIC_BASE_URL).toBeUndefined(); + } finally { + if (originalCodeXHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodeXHome; + } + if (originalCodeXCi === undefined) { + delete process.env.CODEX_CI; + } else { + process.env.CODEX_CI = originalCodeXCi; + } + if (originalCodeXThreadId === undefined) { + delete process.env.CODEX_THREAD_ID; + } else { + process.env.CODEX_THREAD_ID = originalCodeXThreadId; + } + if (originalAnthropicBaseUrl === undefined) { + delete process.env.ANTHROPIC_BASE_URL; + } else { + process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl; + } + } }); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index f7e0e2c0..098a2924 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -25,6 +25,21 @@ function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { }; } +function runCodexAlias(args: string[], env: NodeJS.ProcessEnv): RunResult { + const codexEntry = path.join(process.cwd(), 'src', 'bin', 'codex-runtime.ts'); + const result = spawnSync(process.execPath, [codexEntry, ...args], { + encoding: 'utf8', + env, + timeout: 20000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + function readLoggedCodexCalls(logPath: string): string[][] { if (!fs.existsSync(logPath)) { return []; @@ -93,7 +108,7 @@ process.exit(0); fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('ignores numeric CCS_THINKING env overrides for native Codex default mode', () => { + it('does not preflight native Codex default launches when no runtime overrides are needed', () => { if (process.platform === 'win32') return; const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { @@ -108,7 +123,7 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls.at(-1)).toEqual(['fix failing tests']); + expect(calls).toEqual([['fix failing tests']]); }); it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => { @@ -126,7 +141,25 @@ process.exit(0); expect(result.status).toBe(0); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls.at(-1)).toEqual(['fix failing tests']); + expect(calls).toEqual([['fix failing tests']]); + }); + + it('passes ccsx --version straight through to the native Codex binary', () => { + if (process.platform === 'win32') return; + + const result = runCodexAlias(['--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('codex-cli 9.9.9-test'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); }); it('fails fast when native Codex reasoning overrides need unsupported --config support', () => { @@ -145,7 +178,7 @@ process.exit(0); expect(result.status).toBe(1); expect(result.stderr).toContain('does not advertise --config overrides'); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['--version'], ['--help']]); + expect(calls).toEqual([['--help']]); }); it('reports unsupported generic settings profiles before Codex install guidance', () => {