diff --git a/package.json b/package.json index 67ab369a..854a1d37 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", "ui:validate": "cd ui && bun run validate", + "prepack": "bun run build:all", "prepare": "husky", "postinstall": "node scripts/postinstall.js" }, diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 0199dfc1..09a278d7 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -2,6 +2,7 @@ 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, stripAnthropicEnv, stripCodexSessionEnv } from '../utils/shell-executor'; import type { @@ -20,6 +21,7 @@ import { 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']); function formatTomlString(value: string): string { return JSON.stringify(value); @@ -83,10 +85,54 @@ function normalizeCodexReasoningOverride(value: string | number | undefined): st ); } -function ensureExplicitCodexHomeDir(env: NodeJS.ProcessEnv): string | undefined { - const codexHome = env.CODEX_HOME?.trim(); +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 undefined; + return { env: normalizedEnv }; + } + + if (isInformationalCodexInvocation(args)) { + return { env: normalizedEnv }; } try { @@ -94,18 +140,27 @@ function ensureExplicitCodexHomeDir(env: NodeJS.ProcessEnv): string | undefined } catch (err) { const error = err as NodeJS.ErrnoException; if (error.code !== 'EEXIST') { - return `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`; + return { + env: normalizedEnv, + error: `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`, + }; } } try { if (!fs.statSync(codexHome).isDirectory()) { - return `[X] CODEX_HOME path is not a directory: ${codexHome}`; + return { + env: normalizedEnv, + error: `[X] CODEX_HOME path is not a directory: ${codexHome}`, + }; } - return undefined; + return { env: normalizedEnv }; } catch (err) { const error = err as NodeJS.ErrnoException; - return `[X] Unable to access CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`; + return { + env: normalizedEnv, + error: `[X] Unable to access CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`, + }; } } @@ -233,11 +288,12 @@ export class CodexAdapter implements TargetAdapter { return exitWithCleanup(1); } - const codexHomeInitError = ensureExplicitCodexHomeDir(env); - if (codexHomeInitError) { - console.error(codexHomeInitError); + 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); @@ -248,7 +304,7 @@ export class CodexAdapter implements TargetAdapter { child = spawn( 'powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args], - { stdio: 'inherit', windowsHide: true, env } + { stdio: 'inherit', windowsHide: true, env: launchEnv } ); } else if (needsShell) { const cmdString = [codexPath, ...args].map(escapeShellArg).join(' '); @@ -256,10 +312,10 @@ export class CodexAdapter implements TargetAdapter { stdio: 'inherit', windowsHide: true, shell: true, - env, + env: launchEnv, }); } else { - child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env }); + child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env: launchEnv }); } wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => { diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index 31e72423..97cfac1a 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -7,6 +7,22 @@ import type { TargetBinaryInfo } from './target-adapter'; const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides'; const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version']; +function buildWindowsCodexCandidates(matches: string[]): string[] { + const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)); + const bareCandidates = matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)); + const prioritized: string[] = []; + + for (const entry of shellCandidates) { + if (/\.(cmd|bat)$/i.test(entry)) { + prioritized.push(entry.replace(/\.(cmd|bat)$/i, '.ps1')); + } + prioritized.push(entry); + } + + prioritized.push(...bareCandidates); + return [...new Set(prioritized)]; +} + function runCodexProbe(codexPath: string, args: string[]): string | undefined { const isWindows = process.platform === 'win32'; const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); @@ -112,12 +128,7 @@ export function detectCodexCli(): string | null { .map((entry) => entry.trim()) .filter(Boolean); - const candidates = isWindows - ? [ - ...matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)), - ...matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)), - ] - : matches; + const candidates = isWindows ? buildWindowsCodexCandidates(matches) : matches; for (const candidate of candidates) { try { diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index ce95894f..725c0bc1 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -646,7 +646,7 @@ export function summarizeCodexMcpServers(value: unknown): CodexMcpServerDiagnost .sort((left, right) => left.name.localeCompare(right.name)); } -function getCodexSupportMatrix(): CodexSupportMatrixEntry[] { +function getCodexSupportMatrix(supportsManagedRouting: boolean): CodexSupportMatrixEntry[] { return [ { id: 'default', @@ -657,14 +657,18 @@ function getCodexSupportMatrix(): CodexSupportMatrixEntry[] { { id: 'cliproxy-provider-codex', label: 'cliproxy provider=codex', - supported: true, - notes: 'Routed through the CLIProxy Codex Responses bridge.', + supported: supportsManagedRouting, + notes: supportsManagedRouting + ? 'Routed through the CLIProxy Codex Responses bridge.' + : 'Requires a Codex build that exposes --config overrides.', }, { id: 'settings-with-bridge', label: 'settings with bridge metadata', - supported: true, - notes: 'Supported when the resolved API profile points at a Codex CLIProxy bridge.', + supported: supportsManagedRouting, + notes: supportsManagedRouting + ? 'Supported when the resolved API profile points at a Codex CLIProxy bridge.' + : 'Requires a Codex build that exposes --config overrides.', }, { id: 'cliproxy-composite', @@ -696,6 +700,7 @@ function getCodexSupportMatrix(): CodexSupportMatrixEntry[] { export async function getCodexDashboardDiagnostics(): Promise { const paths = resolveCodexConfigPaths(); const binaryInfo = getCodexBinaryInfo(); + const supportsConfigOverrides = !!binaryInfo && codexBinarySupportsConfigOverrides(binaryInfo); const docsReference = getCompatibleCliDocsReference('codex'); const fileProbe = await probeTomlObjectFile( paths.configPath, @@ -715,12 +720,12 @@ export async function getCodexDashboardDiagnostics(): Promise { 'dedicated ccsxp shortcut entrypoint should exist' ); assert(packageJson.scripts, 'package.json should have scripts field'); + assert.strictEqual( + packageJson.scripts.prepack, + 'bun run build:all', + 'prepack should rebuild packaged assets before npm pack/publish' + ); }); }); }); diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts index 69f71db1..bf4b0f4a 100644 --- a/tests/unit/targets/codex-detector.test.ts +++ b/tests/unit/targets/codex-detector.test.ts @@ -73,6 +73,20 @@ describe('codex-detector', () => { execFileSyncSpy.mockRestore(); }); + it('prefers a sibling PowerShell wrapper over cmd when Windows PATH only exposes codex.cmd', () => { + const fakeCmdCodex = path.join(tmpDir, 'codex.cmd'); + const fakePsCodex = path.join(tmpDir, 'codex.ps1'); + fs.writeFileSync(fakeCmdCodex, ''); + fs.writeFileSync(fakePsCodex, ''); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakeCmdCodex}\n`); + + expect(detectCodexCli()).toBe(fakePsCodex); + + execSyncSpy.mockRestore(); + }); + it('falls back to a direct -c probe when help text omits the config flag', () => { const fakeCodex = path.join(tmpDir, 'codex'); fs.writeFileSync(fakeCodex, ''); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 83723f67..439e476d 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -270,11 +270,11 @@ process.exit(0); ]); }); - it('creates an explicit CODEX_HOME directory before launching native Codex', () => { + it('creates an explicit CODEX_HOME directory before routed native Codex launches', () => { if (process.platform === 'win32') return; const freshCodexHome = path.join(tmpHome, 'fresh-codex-home'); - const result = runCcsxpAlias(['--version'], { + const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], { ...process.env, CI: '1', NO_COLOR: '1', @@ -289,25 +289,31 @@ process.exit(0); expect(result.status).toBe(0); expect(fs.existsSync(freshCodexHome)).toBe(true); expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: freshCodexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - }, + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], ]); + const loggedEnv = readLoggedCodexEnv(codexEnvLogPath); + expect(loggedEnv).toHaveLength(2); + expect(loggedEnv.map((entry) => entry.CODEX_HOME)).toEqual([freshCodexHome, freshCodexHome]); + expect(loggedEnv[1]).toEqual({ + CODEX_HOME: freshCodexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + }); }); - it('fails with a clean error when CODEX_HOME points to a file', () => { + it('fails with a clean error when routed launches receive a file CODEX_HOME path', () => { if (process.platform === 'win32') return; const invalidCodexHome = path.join(tmpHome, 'codex-home-file'); fs.writeFileSync(invalidCodexHome, 'not-a-directory'); - const result = runCcsxpAlias(['--version'], { + const result = runCcs( + ['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], + { ...process.env, CI: '1', NO_COLOR: '1', @@ -315,11 +321,67 @@ process.exit(0); CCS_CODEX_PATH: fakeCodexPath, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, CODEX_HOME: invalidCodexHome, - }); + } + ); expect(result.status).toBe(1); expect(result.stderr).toContain(`[X] CODEX_HOME path is not a directory: ${invalidCodexHome}`); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['-c', 'model="gpt-5"', '--version']]); + }); + + it('keeps passthrough version launches aligned with native warning-only CODEX_HOME behavior', () => { + if (process.platform === 'win32') return; + + const readOnlyRoot = path.join(tmpHome, 'readonly-root'); + fs.mkdirSync(readOnlyRoot, { recursive: true }); + fs.chmodSync(readOnlyRoot, 0o555); + + try { + 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', + CODEX_HOME: path.join(readOnlyRoot, 'missing-codex-home'), + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('codex-cli 9.9.9-test'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); + } finally { + fs.chmodSync(readOnlyRoot, 0o755); + } + }); + + it('normalizes explicit CODEX_HOME before launching native Codex', () => { + if (process.platform === 'win32') return; + + const result = runCodexAlias(['--version'], { + ...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_VERSION: 'codex-cli 9.9.9-test', + CODEX_HOME: '~/.codex-lit', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: path.join(tmpHome, '.codex-lit'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + }, + ]); }); it('keeps ccsxp pinned to native Codex even when a user passes another --target override', () => { diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index 7dc64be6..31c9ef65 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -318,6 +318,12 @@ bearer_token = "secret" expect(diagnostics.warnings.some((warning) => warning.includes('inline bearer_token'))).toBe( true ); + expect( + diagnostics.supportMatrix.find((entry) => entry.id === 'cliproxy-provider-codex')?.supported + ).toBe(false); + expect( + diagnostics.supportMatrix.find((entry) => entry.id === 'settings-with-bridge')?.supported + ).toBe(false); }); it('saves valid raw config content', async () => { diff --git a/ui/src/components/compatible-cli/codex-overview-tab.tsx b/ui/src/components/compatible-cli/codex-overview-tab.tsx index ee0bcda3..f74cd945 100644 --- a/ui/src/components/compatible-cli/codex-overview-tab.tsx +++ b/ui/src/components/compatible-cli/codex-overview-tab.tsx @@ -62,6 +62,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) { const inspectProfileCommand = diagnostics.config.activeProfile ? `codex --profile ${diagnostics.config.activeProfile}` : 'codex'; + const supportsManagedRouting = diagnostics.binary.supportsConfigOverrides; return ( @@ -142,29 +143,42 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) { -

- There are two supported paths. Use ccsxp if you want the built-in CCS - Codex provider shortcut. Use the saved recipe below if you want plain{' '} - codex or a personal alias like cxp to default to CLIProxy. -

-
-

Saved native Codex recipe

-
-                {CLIPROXY_NATIVE_CODEX_RECIPE}
-              
-
-
+ {supportsManagedRouting ? ( + <> +

+ There are two supported paths. Use ccsxp if you want the built-in CCS + Codex provider shortcut. Use the saved recipe below if you want plain{' '} + codex or a personal alias like cxp to default to + CLIProxy. +

+
+

Saved native Codex recipe

+
+                    {CLIPROXY_NATIVE_CODEX_RECIPE}
+                  
+
+
+

+ 1. Save a provider named cliproxy with the base URL and env key + above. +

+

+ 2. In Top-level settings, set Default provider{' '} + to cliproxy. +

+

+ 3. Export CLIPROXY_API_KEY in your shell before launching native + Codex. +

+
+ + ) : (

- 1. Save a provider named cliproxy with the base URL and env key above. + This Codex build can still use the native path, but CCS-backed Codex routing via{' '} + ccsxp or ccs codex --target codex stays unavailable until + the detected Codex binary exposes --config overrides.

-

- 2. In Top-level settings, set Default provider to{' '} - cliproxy. -

-

- 3. Export CLIPROXY_API_KEY in your shell before launching native Codex. -

-
+ )}
@@ -275,12 +289,16 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) { { label: 'CCS Codex shortcut', command: 'ccsxp "your prompt"', - description: 'Run the built-in CCS Codex provider on native Codex.', + description: supportsManagedRouting + ? 'Run the built-in CCS Codex provider on native Codex.' + : 'Requires a Codex build that exposes --config overrides.', }, { label: 'Explicit provider route', command: 'ccs codex --target codex "your prompt"', - description: 'Use the explicit built-in Codex provider route.', + description: supportsManagedRouting + ? 'Use the explicit built-in Codex provider route.' + : 'Requires a Codex build that exposes --config overrides.', }, { label: diagnostics.config.activeProfile @@ -312,9 +330,19 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {

CCS Codex provider / bridge

- Use ccsxp or ccs codex --target codex when you want the - built-in CCS Codex provider on native Codex. That path uses transient CCS-managed - overrides and is separate from the saved cliproxy recipe above. + {supportsManagedRouting ? ( + <> + Use ccsxp or ccs codex --target codex when you want + the built-in CCS Codex provider on native Codex. That path uses transient + CCS-managed overrides and is separate from the saved cliproxy{' '} + recipe above. + + ) : ( + <> + The CCS Codex provider route is currently unavailable because the detected Codex + build does not expose --config overrides. + + )}