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..d4491288 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, @@ -14,6 +14,7 @@ import { codexBinarySupportsConfigOverrides, detectCodexCli, getCodexBinaryInfo, + readCodexVersion, } from './codex-detector'; const CODEX_RUNTIME_PROVIDER_ID = 'ccs_runtime'; @@ -35,6 +36,17 @@ function buildConfigOverrideSupportError(binaryInfo?: TargetBinaryInfo): Error { ); } +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(); @@ -76,7 +88,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 { @@ -99,7 +111,7 @@ export class CodexAdapter implements TargetAdapter { if (profileType === 'default') { if (reasoningOverride) { if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { - throw buildConfigOverrideSupportError(options?.binaryInfo); + throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); } return [ ...buildConfigOverrideArgs([ @@ -112,7 +124,7 @@ export class CodexAdapter implements TargetAdapter { } if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { - throw buildConfigOverrideSupportError(options?.binaryInfo); + throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); } if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) { @@ -148,7 +160,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..faf52a35 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -45,7 +45,7 @@ function runCodexProbe(codexPath: string, args: string[]): string | undefined { } } -function readCodexVersion(codexPath: string): string | undefined { +export function readCodexVersion(codexPath: string): string | undefined { return runCodexProbe(codexPath, ['--version'])?.trim(); } @@ -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..063a8744 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -165,24 +165,74 @@ 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 originalCodeXManagedByBun = process.env.CODEX_MANAGED_BY_BUN; + 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_MANAGED_BY_BUN = '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_MANAGED_BY_BUN).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_MANAGED_BY_BUN).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 (originalCodeXManagedByBun === undefined) { + delete process.env.CODEX_MANAGED_BY_BUN; + } else { + process.env.CODEX_MANAGED_BY_BUN = originalCodeXManagedByBun; + } + 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..161348fd 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 []; @@ -38,11 +53,25 @@ function readLoggedCodexCalls(logPath: string): string[][] { .map((line) => JSON.parse(line) as string[]); } +function readLoggedCodexEnv(logPath: string): Record[] { + if (!fs.existsSync(logPath)) { + return []; + } + + return fs + .readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + describe('codex runtime integration', () => { let tmpHome: string; let ccsDir: string; let fakeCodexPath: string; let codexArgsLogPath: string; + let codexEnvLogPath: string; let emptyPathDir: string; beforeEach(() => { @@ -54,6 +83,7 @@ describe('codex runtime integration', () => { ccsDir = path.join(tmpHome, '.ccs'); fakeCodexPath = path.join(tmpHome, 'fake-codex.js'); codexArgsLogPath = path.join(tmpHome, 'codex-args.log'); + codexEnvLogPath = path.join(tmpHome, 'codex-env.log'); emptyPathDir = path.join(tmpHome, 'empty-bin'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -67,11 +97,24 @@ const out = process.env.CCS_TEST_CODEX_ARGS_OUT; if (out) { fs.appendFileSync(out, JSON.stringify(process.argv.slice(2)) + '\\n'); } -if (process.argv[2] === '--version') { +const envOut = process.env.CCS_TEST_CODEX_ENV_OUT; +if (envOut) { + fs.appendFileSync( + envOut, + JSON.stringify({ + CODEX_HOME: process.env.CODEX_HOME, + CODEX_CI: process.env.CODEX_CI, + CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN, + CODEX_THREAD_ID: process.env.CODEX_THREAD_ID, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + }) + '\\n' + ); +} +if (process.argv[2] === '--version' || process.argv[2] === '-v') { process.stdout.write(process.env.CCS_TEST_CODEX_VERSION || 'codex-cli 0.118.0-alpha.3'); process.exit(0); } -if (process.argv[2] === '--help') { +if (process.argv[2] === '--help' || process.argv[2] === '-h') { process.stdout.write( process.env.CCS_TEST_CODEX_HELP || ' -c, --config \\n -p, --profile \\n' @@ -93,7 +136,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 +151,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 +169,79 @@ 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']]); + }); + + for (const versionFlag of ['--version', '-v']) { + it(`passes ccsx ${versionFlag} straight through to the native Codex binary`, () => { + if (process.platform === 'win32') return; + + const result = runCodexAlias([versionFlag], { + ...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([[versionFlag]]); + }); + } + + for (const helpFlag of ['--help', '-h']) { + it(`passes ccsx ${helpFlag} straight through to the native Codex binary`, () => { + if (process.platform === 'win32') return; + + const result = runCodexAlias([helpFlag], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_HELP: 'codex native help text', + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('codex native help text'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([[helpFlag]]); + }); + } + + it('strips nested Codex session env from passthrough launches while keeping CODEX_HOME', () => { + 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_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CODEX_HOME: '/tmp/codex-home', + CODEX_CI: '1', + CODEX_MANAGED_BY_BUN: '1', + CODEX_THREAD_ID: 'thread-123', + ANTHROPIC_BASE_URL: 'https://stale-proxy.invalid', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: '/tmp/codex-home', + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + }, + ]); }); it('fails fast when native Codex reasoning overrides need unsupported --config support', () => { @@ -139,13 +254,15 @@ process.exit(0); CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', CCS_TEST_CODEX_HELP: ' -p, --profile \\n', }); expect(result.status).toBe(1); + expect(result.stderr).toContain('Codex CLI (codex-cli 9.9.9-test)'); expect(result.stderr).toContain('does not advertise --config overrides'); const calls = readLoggedCodexCalls(codexArgsLogPath); - expect(calls).toEqual([['--version'], ['--help']]); + expect(calls).toEqual([['--help'], ['--version']]); }); it('reports unsupported generic settings profiles before Codex install guidance', () => {