diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 5d203769..afe1d761 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -273,7 +273,8 @@ src/ ### Native Codex Runtime Target - Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`. -- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. +- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process. +- Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance. - `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime. - Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`. - Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex. diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 5af341ee..f3d06468 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -521,6 +521,9 @@ ccsxp → CCS_INTERNAL_ENTRY_TARGET=codex → injects native `model_provider="cliproxy"` override → pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set +→ repairs `[model_providers.cliproxy]` in the active Codex `config.toml` +→ injects the effective CCS CLIProxy auth token into the provider's configured `env_key` +→ ignores the configured CCS default account/profile and stays in native Codex default mode ``` If a user launches CCS through a custom shim instead of the built-in package bins, target diff --git a/src/bin/ccsxp-runtime.ts b/src/bin/ccsxp-runtime.ts index 3e314e34..273b8205 100644 --- a/src/bin/ccsxp-runtime.ts +++ b/src/bin/ccsxp-runtime.ts @@ -1,10 +1,12 @@ const os = require('os'); const path = require('path'); +const { CCSXP_CLIPROXY_SHORTCUT_ENV } = require('../targets/codex-cliproxy-provider-config'); const { stripTargetFlag } = require('../targets/target-resolver'); const { expandPath } = require('../utils/helpers'); const { fail } = require('../utils/ui'); process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; +process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1'; const CCSXP_CLIPROXY_OVERRIDE = 'model_provider="cliproxy"'; const DISALLOWED_CCSXP_CONFIG_KEY_REGEX = /^(model_provider|local_provider|profile)\s*=|^model_providers\./i; diff --git a/src/dispatcher/__tests__/profile-resolver.test.ts b/src/dispatcher/__tests__/profile-resolver.test.ts index 3200f9e3..5361eb1f 100644 --- a/src/dispatcher/__tests__/profile-resolver.test.ts +++ b/src/dispatcher/__tests__/profile-resolver.test.ts @@ -252,6 +252,65 @@ describe('resolveProfileAndTarget', () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it('keeps explicit codex cliproxy profile on the Codex target', async () => { + const mockCodexAdapter = { + displayName: 'Codex CLI', + detectBinary: mock(() => ({ path: '/usr/local/bin/codex', version: '1.0.0' })), + supportsProfileType: mock(() => true), + }; + mockGetTarget.mockImplementation((_name: string) => mockCodexAdapter); + mockResolveTargetType.mockImplementation((_args: string[]) => 'codex' as const); + mockStripTargetFlag.mockImplementation((args: string[]) => { + const next: string[] = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '--target') { + index += 1; + continue; + } + next.push(args[index]); + } + return next; + }); + mockDetectProfile.mockImplementation((args: string[]) => ({ + profile: args[0] || 'default', + remainingArgs: args.slice(1), + })); + mockDetectProfileType.mockImplementation((profile: string) => + profile === 'codex' + ? { + type: 'cliproxy' as const, + name: 'codex', + provider: 'codex', + target: undefined, + } + : { + type: 'account' as const, + name: 'work', + target: undefined, + } + ); + + const result = await resolveProfileAndTarget({ + args: ['codex', '--target', 'codex', 'fix failing tests'], + browserLaunchOverride: undefined, + cliLogger: stubLogger, + }); + + expect(result.profile).toBe('codex'); + expect(result.profileInfo.type).toBe('cliproxy'); + expect(result.resolvedTarget).toBe('codex'); + expect(result.targetAdapter).toBe(mockCodexAdapter); + expect(result.targetRemainingArgs).toEqual(['fix failing tests']); + expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'codex', + }) + ); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it('resolves settings profile (glm) with --target droid and loads settings', async () => { // Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude'). const mockDroidAdapter = { diff --git a/src/dispatcher/profile-resolver.ts b/src/dispatcher/profile-resolver.ts index bff9cbd0..dcbbfd9e 100644 --- a/src/dispatcher/profile-resolver.ts +++ b/src/dispatcher/profile-resolver.ts @@ -79,6 +79,18 @@ export interface ResolvedProfile { detector: InstanceType; } +function usesImplicitDefaultProfile(cleanArgs: string[]): boolean { + return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true; +} + +function buildNativeCodexDefaultProfile(): ProfileDetectionResult { + return { + type: 'default', + name: 'default', + message: 'Using native Codex auth; CCS default profile is not applied to Codex target.', + }; +} + // ========== Profile and Target Resolver ========== /** @@ -112,7 +124,7 @@ export async function resolveProfileAndTarget( // Detect profile (strip --target flags before profile detection) const cleanArgs = stripTargetFlag(args); const { profile, remainingArgs } = detectProfile(cleanArgs); - const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); + let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); let resolvedTarget: ReturnType; try { @@ -127,6 +139,10 @@ export async function resolveProfileAndTarget( throw error; } + if (resolvedTarget === 'codex' && usesImplicitDefaultProfile(cleanArgs)) { + profileInfo = buildNativeCodexDefaultProfile(); + } + // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) const claudeCliRaw = detectClaudeCli(); if (resolvedTarget === 'claude' && !claudeCliRaw) { diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 31cf2104..ad3c805a 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -24,6 +24,14 @@ import { 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 { + CCSXP_CLIPROXY_SHORTCUT_ENV, + CODEX_CLIPROXY_PROVIDER_ENV_KEY, + ensureCodexCliproxyProviderConfig, + isCcsxpCliproxyShortcut, +} from './codex-cliproxy-provider-config'; const adapterLogger = createLogger('targets:codex'); @@ -176,13 +184,25 @@ function prepareExplicitCodexHome( 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 { - // Codex uses transient -c overrides plus env_key injection. + 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( @@ -258,7 +278,11 @@ export class CodexAdapter implements TargetAdapter { 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.'); diff --git a/src/targets/codex-cliproxy-provider-config.ts b/src/targets/codex-cliproxy-provider-config.ts new file mode 100644 index 00000000..6ef94a23 --- /dev/null +++ b/src/targets/codex-cliproxy-provider-config.ts @@ -0,0 +1,174 @@ +import * as os from 'os'; +import * as path from 'path'; +import { expandPath } from '../utils/helpers'; +import { + probeTomlObjectFile, + stringifyTomlObject, + writeTomlFileAtomic, +} from '../web-server/services/compatible-cli-toml-file-service'; + +export const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT'; +export const CODEX_CLIPROXY_PROVIDER_ID = 'cliproxy'; +export const CODEX_CLIPROXY_PROVIDER_ENV_KEY = 'CLIPROXY_API_KEY'; +export const CODEX_CLIPROXY_PROVIDER_NAME = 'CLIProxy Codex'; + +export interface CodexCliproxyProviderRepairResult { + changed: boolean; + configPath: string; + displayPath: string; + envKey: string; +} + +function resolveCodexConfigPath(env: NodeJS.ProcessEnv = process.env): { + configPath: string; + displayPath: string; +} { + const baseDir = path.resolve( + env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(os.homedir(), '.codex') + ); + const displayBase = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex'; + return { + configPath: path.join(baseDir, 'config.toml'), + displayPath: `${displayBase}/config.toml`, + }; +} + +export function buildCodexCliproxyProviderBaseUrl(port: number): string { + return `http://127.0.0.1:${port}/api/provider/codex`; +} + +export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean { + return env[CCSXP_CLIPROXY_SHORTCUT_ENV] === '1'; +} + +function asObject(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeLocalProviderUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + if ( + (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && + url.pathname === '/api/provider/codex' + ) { + url.hostname = '127.0.0.1'; + return url.toString().replace(/\/$/, ''); + } + } catch { + return null; + } + return null; +} + +function resolveProviderEnvKey(provider: Record | null): string { + const envKey = provider?.env_key; + if (typeof envKey === 'string' && envKey.trim()) { + return envKey.trim(); + } + return CODEX_CLIPROXY_PROVIDER_ENV_KEY; +} + +function isProviderReady( + provider: Record, + expectedBaseUrl: string, + envKey: string +): boolean { + return ( + provider.name === CODEX_CLIPROXY_PROVIDER_NAME && + normalizeLocalProviderUrl(provider.base_url) === expectedBaseUrl && + provider.env_key === envKey && + provider.wire_api === 'responses' && + provider.requires_openai_auth === false && + provider.supports_websockets === false + ); +} + +function buildProviderConfig(baseUrl: string, envKey: string): Record { + return { + name: CODEX_CLIPROXY_PROVIDER_NAME, + base_url: baseUrl, + env_key: envKey, + wire_api: 'responses', + requires_openai_auth: false, + supports_websockets: false, + }; +} + +function buildProviderBlock(baseUrl: string): string { + return stringifyTomlObject({ + model_providers: { + [CODEX_CLIPROXY_PROVIDER_ID]: buildProviderConfig(baseUrl, CODEX_CLIPROXY_PROVIDER_ENV_KEY), + }, + }); +} + +function appendProviderBlock(rawText: string, baseUrl: string): string { + const prefix = rawText.trimEnd(); + const providerBlock = buildProviderBlock(baseUrl).trimEnd(); + return prefix ? `${prefix}\n\n${providerBlock}\n` : `${providerBlock}\n`; +} + +export async function ensureCodexCliproxyProviderConfig( + port: number, + env: NodeJS.ProcessEnv = process.env +): Promise { + const { configPath, displayPath } = resolveCodexConfigPath(env); + const fileProbe = await probeTomlObjectFile(configPath, 'Codex user config', displayPath); + + if (fileProbe.diagnostics.readError) { + throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`); + } + if (fileProbe.diagnostics.parseError) { + throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`); + } + + const config = fileProbe.config ?? {}; + const modelProvidersValue = config.model_providers; + const providers = asObject(modelProvidersValue); + const expectedBaseUrl = buildCodexCliproxyProviderBaseUrl(port); + + if (modelProvidersValue !== undefined && !providers) { + throw new Error(`Cannot repair ${displayPath}: [model_providers] must be a table.`); + } + + if (!providers || !Object.prototype.hasOwnProperty.call(providers, CODEX_CLIPROXY_PROVIDER_ID)) { + await writeTomlFileAtomic({ + filePath: configPath, + rawText: appendProviderBlock(fileProbe.rawText, expectedBaseUrl), + expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined, + fileLabel: 'config.toml', + }); + return { changed: true, configPath, displayPath, envKey: CODEX_CLIPROXY_PROVIDER_ENV_KEY }; + } + + const currentProvider = asObject(providers[CODEX_CLIPROXY_PROVIDER_ID]); + if (!currentProvider) { + throw new Error( + `Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.` + ); + } + + const envKey = resolveProviderEnvKey(currentProvider); + + if (isProviderReady(currentProvider, expectedBaseUrl, envKey)) { + return { changed: false, configPath, displayPath, envKey }; + } + + providers[CODEX_CLIPROXY_PROVIDER_ID] = { + ...currentProvider, + ...buildProviderConfig(expectedBaseUrl, envKey), + }; + + await writeTomlFileAtomic({ + filePath: configPath, + rawText: stringifyTomlObject(config), + expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined, + fileLabel: 'config.toml', + }); + return { changed: true, configPath, displayPath, envKey }; +} diff --git a/src/targets/target-runtime-compatibility.ts b/src/targets/target-runtime-compatibility.ts index 627479cf..a667417b 100644 --- a/src/targets/target-runtime-compatibility.ts +++ b/src/targets/target-runtime-compatibility.ts @@ -46,7 +46,7 @@ export function evaluateTargetRuntimeCompatibility( if (input.profileType === 'account') { return unsupported( 'Codex CLI does not support Claude account-based profiles.', - 'Use native Codex auth with: ccs --target codex' + 'Native Codex: ccs --target codex. CLIProxy Codex pool: ccs codex --target codex or ccsxp. Manage pool/routing with: ccs codex --auth and ccs cliproxy routing.' ); } diff --git a/tests/unit/targets/codex-cliproxy-provider-config.test.ts b/tests/unit/targets/codex-cliproxy-provider-config.test.ts new file mode 100644 index 00000000..43dd6b28 --- /dev/null +++ b/tests/unit/targets/codex-cliproxy-provider-config.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + buildCodexCliproxyProviderBaseUrl, + ensureCodexCliproxyProviderConfig, +} from '../../../src/targets/codex-cliproxy-provider-config'; + +describe('codex cliproxy provider config repair', () => { + let tempHome: string; + let codexHome: string; + let configPath: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-provider-config-')); + codexHome = path.join(tempHome, '.codex'); + configPath = path.join(codexHome, 'config.toml'); + env = { CODEX_HOME: codexHome } as NodeJS.ProcessEnv; + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('creates the cliproxy model provider when config.toml is missing', async () => { + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain('[model_providers.cliproxy]'); + expect(rawText).toContain('name = "CLIProxy Codex"'); + expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(rawText).not.toContain('model_provider = "cliproxy"'); + }); + + it('appends the missing provider while preserving existing raw config text', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(configPath, '# user note\nmodel = "gpt-5.4"\n', 'utf8'); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText.startsWith('# user note\nmodel = "gpt-5.4"\n\n')).toBe(true); + expect(rawText).toContain('[model_providers.cliproxy]'); + }); + + it('repairs an existing incomplete cliproxy provider', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + configPath, + `[model_providers.cliproxy] +base_url = "http://localhost:8317/api/provider/codex" +wire_api = "responses" +`, + 'utf8' + ); + + const result = await ensureCodexCliproxyProviderConfig(9321, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`); + expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(rawText).toContain('requires_openai_auth = false'); + expect(rawText).toContain('supports_websockets = false'); + }); + + it('preserves a custom cliproxy provider env key while repairing other fields', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + configPath, + `[model_providers.cliproxy] +name = "Old Name" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CCS_CUSTOM_CLIPROXY_TOKEN" +wire_api = "chat" +`, + 'utf8' + ); + + const result = await ensureCodexCliproxyProviderConfig(9321, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CCS_CUSTOM_CLIPROXY_TOKEN'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`); + expect(rawText).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"'); + expect(rawText).toContain('wire_api = "responses"'); + }); + + it('rejects invalid non-table model_providers values without appending broken TOML', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + const rawText = 'model_providers = "legacy"\n'; + fs.writeFileSync(configPath, rawText, 'utf8'); + + await expect(ensureCodexCliproxyProviderConfig(8317, env)).rejects.toThrow( + '[model_providers] must be a table' + ); + expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); + }); + + it('leaves a ready localhost provider unchanged', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + const rawText = `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`; + fs.writeFileSync(configPath, rawText, 'utf8'); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(false); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); + }); +}); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 318219c6..8e916942 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -116,18 +116,32 @@ if (out) { } const envOut = process.env.CCS_TEST_CODEX_ENV_OUT; if (envOut) { + const loggedEnv = { + 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, + CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR, + CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR, + CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL, + }; + if ( + process.env.CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY === '1' && + process.env.CLIPROXY_API_KEY !== undefined + ) { + loggedEnv.CLIPROXY_API_KEY = process.env.CLIPROXY_API_KEY; + } + const extraEnvKeys = (process.env.CCS_TEST_CODEX_LOG_ENV_KEYS || '') + .split(',') + .map((key) => key.trim()) + .filter(Boolean); + for (const key of extraEnvKeys) { + loggedEnv[key] = process.env[key]; + } 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, - CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR, - CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR, - CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL, - }) + '\\n' + JSON.stringify(loggedEnv) + '\\n' ); } const configFlagIndex = cliArgs.findIndex((arg) => arg === '-c' || arg === '--config'); @@ -396,6 +410,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -413,6 +428,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -647,6 +663,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -668,21 +685,24 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', CODEX_HOME: inheritedCodexHome, }); expect(result.status).toBe(0); expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ { - CODEX_HOME: path.join(os.homedir(), '.codex'), + CODEX_HOME: path.join(tmpHome, '.codex'), CODEX_CI: undefined, CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', CCS_BROWSER_USER_DATA_DIR: undefined, CCS_BROWSER_PROFILE_DIR: undefined, CCS_BROWSER_DEVTOOLS_WS_URL: undefined, @@ -697,10 +717,112 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + ]); + const codexConfig = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf8'); + expect(codexConfig).toContain('[model_providers.cliproxy]'); + expect(codexConfig).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('loads the configured cliproxy provider env_key for ccsxp launches', () => { + if (process.platform === 'win32') return; + + const codexHome = path.join(tmpHome, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CCS_CUSTOM_CLIPROXY_TOKEN" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`, + 'utf8' + ); + + const result = runCcsxpAlias(['fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_LOG_ENV_KEYS: 'CCS_CUSTOM_CLIPROXY_TOKEN', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + ]); + const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + expect(codexConfig).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"'); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: codexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('keeps ccsxp native when the CCS default profile is a Claude account', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 2', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-01-01"', + ' last_used: "2026-01-01"', + ].join('\n') + ); + + const result = runCcsxpAlias(['fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', }); expect(result.status).toBe(0); @@ -709,7 +831,51 @@ process.exit(0); ]); expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ { - CODEX_HOME: path.join(os.homedir(), '.codex'), + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }, + ]); + }); + + it('keeps implicit ccs --target codex launches native when the CCS default is a Claude account', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 2', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-01-01"', + ' last_used: "2026-01-01"', + ].join('\n') + ); + + const result = runCcs(['--target', 'codex'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_THINKING: '8192', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([[]]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: undefined, CODEX_CI: undefined, CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, @@ -721,6 +887,35 @@ process.exit(0); ]); }); + it('still rejects an explicit Claude account profile on the Codex target', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 2', + 'accounts:', + ' work:', + ' created: "2026-01-01"', + ' last_used: "2026-01-01"', + ].join('\n') + ); + + const result = runCcs(['work', '--target', 'codex', 'fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Codex CLI does not support Claude account-based profiles.'); + expect(result.stderr).toContain('CLIProxy Codex pool: ccs codex --target codex or ccsxp'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]); + }); + it('rejects conflicting native provider config overrides for ccsxp', () => { if (process.platform === 'win32') return; @@ -728,6 +923,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -745,6 +941,7 @@ process.exit(0); ...process.env, CI: '1', NO_COLOR: '1', + HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, @@ -767,6 +964,7 @@ process.exit(0); CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1', CODEX_HOME: path.join(tmpHome, 'inherited-codex-home'), CCSXP_CODEX_HOME: explicitCodexHome, }); @@ -779,6 +977,7 @@ process.exit(0); CODEX_MANAGED_BY_BUN: undefined, CODEX_THREAD_ID: undefined, ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', CCS_BROWSER_USER_DATA_DIR: undefined, CCS_BROWSER_PROFILE_DIR: undefined, CCS_BROWSER_DEVTOOLS_WS_URL: undefined, diff --git a/tests/unit/targets/target-runtime-compatibility.test.ts b/tests/unit/targets/target-runtime-compatibility.test.ts index 60a2d1fa..923de185 100644 --- a/tests/unit/targets/target-runtime-compatibility.test.ts +++ b/tests/unit/targets/target-runtime-compatibility.test.ts @@ -87,16 +87,21 @@ describe('evaluateTargetRuntimeCompatibility', () => { profileType: 'settings', }); expect(genericSettingsCompatibility.supported).toBe(false); - expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/); + expect(genericSettingsCompatibility.reason).toMatch( + /currently supports native default sessions/ + ); }); test('rejects account, copilot, and cursor profiles on Codex target', () => { - expect( - evaluateTargetRuntimeCompatibility({ - target: 'codex', - profileType: 'account', - }).supported - ).toBe(false); + const accountCompatibility = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'account', + }); + expect(accountCompatibility.supported).toBe(false); + expect(accountCompatibility.suggestion).toContain('ccs --target codex'); + expect(accountCompatibility.suggestion).toContain('ccs codex --target codex'); + expect(accountCompatibility.suggestion).toContain('ccs cliproxy routing'); + expect( evaluateTargetRuntimeCompatibility({ target: 'codex', diff --git a/ui/src/lib/codex-config.ts b/ui/src/lib/codex-config.ts index 41559f6c..cb734173 100644 --- a/ui/src/lib/codex-config.ts +++ b/ui/src/lib/codex-config.ts @@ -58,9 +58,12 @@ export interface CodexFeatureCatalogEntry { export const CLIPROXY_NATIVE_CODEX_RECIPE = `model_provider = "cliproxy" [model_providers.cliproxy] +name = "CLIProxy Codex" base_url = "http://127.0.0.1:8317/api/provider/codex" env_key = "CLIPROXY_API_KEY" -wire_api = "responses"`; +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false`; export const KNOWN_CODEX_FEATURE_NAMES = [ 'multi_agent',