From 8f60820f3358d878e0f4b9cd448b00690d9b4406 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 19:03:03 -0400 Subject: [PATCH 01/10] feat(targets): add native codex runtime target - add a Codex adapter, detector, runtime aliases, and compatibility matrix - keep Codex runtime-only while preserving persisted targets for claude and droid - cover Codex launch, reasoning, wrapper detection, and bridge routing regressions Refs #773 --- package.json | 4 +- src/bin/codex-runtime.ts | 2 + src/ccs.ts | 112 +++++++-- src/targets/codex-adapter.ts | 234 ++++++++++++++++++ src/targets/codex-detector.ts | 138 +++++++++++ src/targets/index.ts | 16 ++ src/targets/target-adapter.ts | 14 +- src/targets/target-metadata.ts | 87 +++++++ src/targets/target-resolver.ts | 42 ++-- src/targets/target-runtime-compatibility.ts | 91 +++++++ tests/npm/cross-platform.test.js | 16 ++ .../targets/ccsd-alias-integration.test.ts | 22 ++ tests/unit/targets/codex-adapter.test.ts | 140 +++++++++++ tests/unit/targets/codex-detector.test.ts | 75 ++++++ .../codex-settings-bridge-launch.test.ts | 135 ++++++++++ tests/unit/targets/target-registry.test.ts | 10 + tests/unit/targets/target-resolver.test.ts | 91 ++++++- .../target-runtime-compatibility.test.ts | 79 ++++++ 18 files changed, 1254 insertions(+), 54 deletions(-) create mode 100644 src/bin/codex-runtime.ts create mode 100644 src/targets/codex-adapter.ts create mode 100644 src/targets/codex-detector.ts create mode 100644 src/targets/target-metadata.ts create mode 100644 src/targets/target-runtime-compatibility.ts create mode 100644 tests/unit/targets/codex-adapter.test.ts create mode 100644 tests/unit/targets/codex-detector.test.ts create mode 100644 tests/unit/targets/codex-settings-bridge-launch.test.ts create mode 100644 tests/unit/targets/target-runtime-compatibility.test.ts diff --git a/package.json b/package.json index 2a178cf2..740a24a5 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "bin": { "ccs": "dist/ccs.js", "ccs-droid": "dist/bin/droid-runtime.js", - "ccsd": "dist/bin/droid-runtime.js" + "ccsd": "dist/bin/droid-runtime.js", + "ccs-codex": "dist/bin/codex-runtime.js", + "ccsx": "dist/bin/codex-runtime.js" }, "files": [ "dist/", diff --git a/src/bin/codex-runtime.ts b/src/bin/codex-runtime.ts new file mode 100644 index 00000000..00478915 --- /dev/null +++ b/src/bin/codex-runtime.ts @@ -0,0 +1,2 @@ +process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; +require('../ccs'); diff --git a/src/ccs.ts b/src/ccs.ts index 395f2748..457536dd 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -56,6 +56,8 @@ import { getTarget, ClaudeAdapter, DroidAdapter, + CodexAdapter, + evaluateTargetRuntimeCompatibility, pruneOrphanedModels, resolveDroidProvider, type TargetCredentials, @@ -66,6 +68,7 @@ import { resolveDroidReasoningRuntime, } from './targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; +import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -183,6 +186,7 @@ async function main(): Promise { // Register target adapters registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); + registerTarget(new CodexAdapter()); const args = process.argv.slice(2); @@ -381,21 +385,25 @@ async function main(): Promise { process.exit(1); } - if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) { - console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`)); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); - } - - if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) { - console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`)); - process.exit(1); - } - - if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) { - console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`)); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); + if (profileInfo.type !== 'settings') { + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined, + isComposite: + profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } } if (profileInfo.type === 'default') { @@ -428,6 +436,8 @@ async function main(): Promise { console.error(fail(`${displayName} CLI not found.`)); if (resolvedTarget === 'droid') { console.error(info('Install: npm i -g @factory/cli')); + } else if (resolvedTarget === 'codex') { + console.error(info('Install a recent @openai/codex build, then retry.')); } process.exit(1); } @@ -446,7 +456,7 @@ async function main(): Promise { } let targetRemainingArgs = remainingArgs; - let droidReasoningOverride: string | number | undefined; + let runtimeReasoningOverride: string | number | undefined; if (resolvedTarget === 'droid') { try { const droidRoute = routeDroidCommandArgs(remainingArgs); @@ -455,7 +465,7 @@ async function main(): Promise { if (droidRoute.mode === 'interactive') { const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); targetRemainingArgs = runtime.argsWithoutReasoningFlags; - droidReasoningOverride = runtime.reasoningOverride; + runtimeReasoningOverride = runtime.reasoningOverride; if (runtime.duplicateDisplays.length > 0) { console.error( @@ -488,6 +498,28 @@ async function main(): Promise { } throw error; } + } else if (resolvedTarget === 'codex') { + try { + const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + runtimeReasoningOverride = runtime.reasoningOverride; + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + console.error(fail(error.message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Codex alias: --effort minimal|low|medium|high|xhigh'); + process.exit(1); + } + throw error; + } } // Special case: headless delegation (-p/--prompt) @@ -625,7 +657,7 @@ async function main(): Promise { baseUrl: envVars['ANTHROPIC_BASE_URL'], model: envVars['ANTHROPIC_MODEL'], }), - reasoningOverride: droidReasoningOverride, + reasoningOverride: runtimeReasoningOverride, envVars, }; @@ -643,7 +675,11 @@ async function main(): Promise { } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: targetBinaryInfo || undefined, + }); const targetEnv = adapter.buildEnv(creds, profileInfo.type); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; @@ -722,6 +758,26 @@ async function main(): Promise { ? expandPath(profileInfo.settingsPath) : getSettingsPath(profileInfo.name); const settings = loadSettings(expandedSettingsPath); + const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); + if (resolvedTarget !== 'claude') { + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: cliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || + `${targetAdapter?.displayName || resolvedTarget} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } const rawSettingsEnv = profileInfo.env ?? settings.env ?? {}; const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name); const glmtNormalization = isDeprecatedGlmtProfile @@ -841,11 +897,15 @@ async function main(): Promise { baseUrl: directAnthropicBaseUrl, model: settingsEnv['ANTHROPIC_MODEL'], }), - reasoningOverride: droidReasoningOverride, + reasoningOverride: runtimeReasoningOverride, envVars, }; await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, { + creds, + profileType: profileInfo.type, + binaryInfo: targetBinaryInfo || undefined, + }); const targetEnv = adapter.buildEnv(creds, profileInfo.type); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; @@ -938,9 +998,9 @@ async function main(): Promise { baseUrl: process.env['ANTHROPIC_BASE_URL'], model: process.env['ANTHROPIC_MODEL'], }), - reasoningOverride: droidReasoningOverride, + reasoningOverride: runtimeReasoningOverride, }; - if (!creds.baseUrl || !creds.apiKey) { + if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) { console.error( fail( `${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` @@ -950,7 +1010,11 @@ async function main(): Promise { process.exit(1); } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs('default', targetRemainingArgs); + const targetArgs = adapter.buildArgs('default', targetRemainingArgs, { + creds, + profileType: 'default', + binaryInfo: targetBinaryInfo || undefined, + }); const targetEnv = adapter.buildEnv(creds, 'default'); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts new file mode 100644 index 00000000..1fb24b60 --- /dev/null +++ b/src/targets/codex-adapter.ts @@ -0,0 +1,234 @@ +import { ChildProcess, spawn } from 'child_process'; +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 type { + TargetAdapter, + TargetBinaryInfo, + TargetCredentials, + TargetType, +} from './target-adapter'; +import { + codexBinarySupportsConfigOverrides, + detectCodexCli, + getCodexBinaryInfo, +} from './codex-detector'; + +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']); + +function formatTomlString(value: string): string { + return JSON.stringify(value); +} + +function buildConfigOverrideArgs(overrides: string[]): string[] { + return overrides.flatMap((override) => ['-c', override]); +} + +function findDisallowedCodexManagedFlags(args: string[]): string[] { + const disallowed = new Set(); + + 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.' + ); +} + +export class CodexAdapter implements TargetAdapter { + readonly type: TargetType = 'codex'; + readonly displayName = 'Codex CLI'; + + detectBinary(): TargetBinaryInfo | null { + return getCodexBinaryInfo(); + } + + async prepareCredentials(_creds: TargetCredentials): Promise { + // Codex uses transient -c overrides plus env_key injection. + } + + 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); + + if (profileType === 'default') { + if (reasoningOverride) { + return [ + ...buildConfigOverrideArgs([ + `model_reasoning_effort=${formatTomlString(reasoningOverride)}`, + ]), + ...userArgs, + ]; + } + return userArgs; + } + + if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + const versionSummary = options?.binaryInfo?.version ? ` (${options.binaryInfo.version})` : ''; + throw new Error( + `Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles.` + ); + } + + 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)}`); + } + + if (reasoningOverride) { + overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); + } + + return [...buildConfigOverrideArgs(overrides), ...userArgs]; + } + + buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...stripAnthropicEnv(process.env) }; + delete env[CODEX_RUNTIME_ENV_KEY]; + 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 isWindows = process.platform === 'win32'; + const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath); + + let child: ChildProcess; + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args], + { stdio: 'inherit', windowsHide: true, env } + ); + } else if (needsShell) { + const cmdString = [codexPath, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env, + }); + } else { + child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env }); + } + + 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 { + return profileType === 'default' || profileType === 'settings' || profileType === 'cliproxy'; + } +} diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts new file mode 100644 index 00000000..07b1a7a5 --- /dev/null +++ b/src/targets/codex-detector.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as childProcess from 'child_process'; +import { expandPath } from '../utils/helpers'; +import { escapeShellArg } from '../utils/shell-executor'; +import type { TargetBinaryInfo } from './target-adapter'; + +const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides'; + +function runCodexProbe(codexPath: string, args: string[]): string | undefined { + const isWindows = process.platform === 'win32'; + const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath); + + try { + if (isPowerShellScript) { + return childProcess.execFileSync( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + windowsHide: true, + } + ); + } + + if (needsShell) { + const cmdString = [codexPath, ...args].map(escapeShellArg).join(' '); + return childProcess.execFileSync('cmd.exe', ['/d', '/s', '/c', cmdString], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + windowsHide: true, + }); + } + + return childProcess.execFileSync(codexPath, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }); + } catch { + return undefined; + } +} + +function readCodexVersion(codexPath: string): string | undefined { + return runCodexProbe(codexPath, ['--version'])?.trim(); +} + +function detectCodexFeatures(codexPath: string): readonly string[] { + const helpText = runCodexProbe(codexPath, ['--help']); + return helpText?.includes('--config ') ? [CODEX_CONFIG_OVERRIDE_FEATURE] : []; +} + +export function detectCodexCli(): string | null { + if (process.env.CCS_CODEX_PATH) { + const customPath = expandPath(process.env.CCS_CODEX_PATH); + try { + if (fs.statSync(customPath).isFile()) { + return customPath; + } + console.warn('[!] CCS_CODEX_PATH points to a directory, not a file:', customPath); + console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.'); + return null; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + console.warn('[!] Warning: CCS_CODEX_PATH is set but file not found:', customPath); + } else { + console.warn( + `[!] Warning: CCS_CODEX_PATH is not accessible (${error.code || 'unknown error'}):`, + customPath + ); + } + console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.'); + return null; + } + } + + const isWindows = process.platform === 'win32'; + try { + const cmd = isWindows ? 'where.exe codex' : 'which codex'; + const result = childProcess + .execSync(cmd, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }) + .trim(); + + const matches = result + .split('\n') + .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; + + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Ignore disappearing PATH candidates. + } + } + } catch { + // codex not in PATH + } + + return null; +} + +export function getCodexBinaryInfo(): TargetBinaryInfo | null { + const codexPath = detectCodexCli(); + if (!codexPath) return null; + + const isWindows = process.platform === 'win32'; + return { + path: codexPath, + needsShell: isWindows && /\.(cmd|bat|ps1)$/i.test(codexPath), + version: readCodexVersion(codexPath), + features: detectCodexFeatures(codexPath), + }; +} + +export function codexBinarySupportsConfigOverrides( + binaryInfo: TargetBinaryInfo | null | undefined +): boolean { + return Boolean(binaryInfo?.features?.includes(CODEX_CONFIG_OVERRIDE_FEATURE)); +} diff --git a/src/targets/index.ts b/src/targets/index.ts index 2d05d240..fba132f8 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -19,7 +19,13 @@ export { } from './target-registry'; export { ClaudeAdapter } from './claude-adapter'; export { DroidAdapter } from './droid-adapter'; +export { CodexAdapter } from './codex-adapter'; export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; +export { + codexBinarySupportsConfigOverrides, + getCodexBinaryInfo, + detectCodexCli, +} from './codex-detector'; export { upsertCcsModel, removeCcsModel, @@ -30,3 +36,13 @@ export type { DroidCustomModel } from './droid-config-manager'; export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider'; export type { DroidProvider } from './droid-provider'; export { resolveTargetType, stripTargetFlag } from './target-resolver'; +export { + TARGET_METADATA, + RUNTIME_TARGET_TYPES, + PERSISTED_TARGET_TYPES, + getPersistedTargetChoices, + getRuntimeTargetChoices, + isPersistedTargetType, + isRuntimeTargetType, +} from './target-metadata'; +export { evaluateTargetRuntimeCompatibility } from './target-runtime-compatibility'; diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index ffe4b890..abf5c3ce 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -11,7 +11,7 @@ */ import type { ProfileType } from '../types/profile'; -export type TargetType = 'claude' | 'droid'; +export type TargetType = 'claude' | 'droid' | 'codex'; /** * Credentials resolved by CCS profile system, ready for delivery to target CLI. @@ -39,6 +39,8 @@ export interface TargetCredentials { export interface TargetBinaryInfo { path: string; needsShell: boolean; // Windows .cmd/.bat/.ps1 + version?: string; + features?: readonly string[]; } /** @@ -72,7 +74,15 @@ export interface TargetAdapter { * Build target-specific argument vector. * `userArgs` are the arguments after CCS profile/flag parsing. */ - buildArgs(profile: string, userArgs: string[]): string[]; + buildArgs( + profile: string, + userArgs: string[], + options?: { + creds?: TargetCredentials; + profileType?: ProfileType; + binaryInfo?: TargetBinaryInfo; + } + ): string[]; /** * Build environment variables for process spawn. diff --git a/src/targets/target-metadata.ts b/src/targets/target-metadata.ts new file mode 100644 index 00000000..af28aeb4 --- /dev/null +++ b/src/targets/target-metadata.ts @@ -0,0 +1,87 @@ +import type { TargetType } from './target-adapter'; + +export interface TargetMetadata { + displayName: string; + runtimeAliases: readonly string[]; + legacyAliasEnvVar?: string; + persistedTarget: boolean; +} + +export const TARGET_METADATA: Record = { + claude: { + displayName: 'Claude Code', + runtimeAliases: [], + persistedTarget: true, + }, + droid: { + displayName: 'Factory Droid', + runtimeAliases: ['ccs-droid', 'ccsd'], + legacyAliasEnvVar: 'CCS_DROID_ALIASES', + persistedTarget: true, + }, + codex: { + displayName: 'Codex CLI', + runtimeAliases: ['ccs-codex', 'ccsx'], + legacyAliasEnvVar: 'CCS_CODEX_ALIASES', + persistedTarget: false, + }, +}; + +export const RUNTIME_TARGET_TYPES = Object.freeze( + Object.keys(TARGET_METADATA) as TargetType[] +) as readonly TargetType[]; + +export const PERSISTED_TARGET_TYPES = Object.freeze( + RUNTIME_TARGET_TYPES.filter((target) => TARGET_METADATA[target].persistedTarget) +) as readonly TargetType[]; + +const RUNTIME_TARGET_SET = new Set(RUNTIME_TARGET_TYPES); +const PERSISTED_TARGET_SET = new Set(PERSISTED_TARGET_TYPES); + +export function isRuntimeTargetType(value: unknown): value is TargetType { + return typeof value === 'string' && RUNTIME_TARGET_SET.has(value as TargetType); +} + +export function isPersistedTargetType(value: unknown): value is TargetType { + return typeof value === 'string' && PERSISTED_TARGET_SET.has(value as TargetType); +} + +export function formatTargetChoices( + targets: readonly TargetType[], + conjunction: 'or' | 'comma' = 'comma' +): string { + if (targets.length === 0) return ''; + if (targets.length === 1) return targets[0]; + if (conjunction === 'comma') return targets.join(', '); + if (targets.length === 2) return `${targets[0]} or ${targets[1]}`; + return `${targets.slice(0, -1).join(', ')}, or ${targets[targets.length - 1]}`; +} + +export function getPersistedTargetChoices(): string { + return formatTargetChoices(PERSISTED_TARGET_TYPES, 'or'); +} + +export function getRuntimeTargetChoices(): string { + return formatTargetChoices(RUNTIME_TARGET_TYPES, 'comma'); +} + +export function getBuiltinArgv0TargetMap(): Record { + const map: Record = {}; + for (const target of RUNTIME_TARGET_TYPES) { + for (const alias of TARGET_METADATA[target].runtimeAliases) { + map[alias] = target; + } + } + return map; +} + +export function getLegacyTargetAliasEnvVars(): Partial> { + const result: Partial> = {}; + for (const target of RUNTIME_TARGET_TYPES) { + const envVar = TARGET_METADATA[target].legacyAliasEnvVar; + if (envVar) { + result[target] = envVar; + } + } + return result; +} diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index a7a5999d..13b4d636 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -10,21 +10,24 @@ import * as path from 'path'; import { TargetType } from './target-adapter'; +import { + getBuiltinArgv0TargetMap, + getLegacyTargetAliasEnvVars, + getRuntimeTargetChoices, + isPersistedTargetType, + isRuntimeTargetType, +} from './target-metadata'; /** * Built-in argv[0] aliases for explicit runtime entrypoints. - * `ccs-droid` is the transparent alias; `ccsd` remains as a legacy shortcut. + * Droid and Codex install dedicated runtime aliases alongside the base `ccs` bin. */ -const BUILTIN_ARGV0_TARGET_MAP: Record = { - 'ccs-droid': 'droid', - ccsd: 'droid', -}; +const BUILTIN_ARGV0_TARGET_MAP: Record = getBuiltinArgv0TargetMap(); const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/; const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET'; const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES'; -const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = { - droid: 'CCS_DROID_ALIASES', -}; +const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = + getLegacyTargetAliasEnvVars(); const RESERVED_BIN_NAMES = new Set(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]); function addAliasToMap(map: Record, alias: string, target: TargetType): void { @@ -64,7 +67,7 @@ function parseGenericTargetAliasConfig(map: Record, rawConfi const rawTarget = entry.slice(0, separatorIndex).trim().toLowerCase(); const rawAliases = entry.slice(separatorIndex + 1).trim(); - if (!rawAliases || !isValidTarget(rawTarget)) { + if (!rawAliases || !isRuntimeTargetType(rawTarget)) { continue; } @@ -100,30 +103,21 @@ function resolveEntrypointTarget(): TargetType | null { } const normalizedTarget = rawTarget.trim().toLowerCase(); - return isValidTarget(normalizedTarget) ? normalizedTarget : null; + return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null; } -/** - * Valid target types for --target flag validation. - */ -const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); - interface ParsedTargetFlags { targetOverride?: TargetType; cleanedArgs: string[]; } -function isValidTarget(target: unknown): target is TargetType { - return typeof target === 'string' && VALID_TARGETS.has(target as TargetType); -} - function normalizeTargetValue(value: string): TargetType { const normalized = value.toLowerCase(); - if (isValidTarget(normalized)) { + if (isRuntimeTargetType(normalized)) { return normalized as TargetType; } - const available = Array.from(VALID_TARGETS).join(', '); + const available = getRuntimeTargetChoices(); throw new Error(`Unknown target "${value}". Available: ${available}`); } @@ -148,7 +142,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags { if (arg === '--target') { const value = args[i + 1]; if (!value || value.startsWith('-')) { - throw new Error('--target requires a value (claude or droid)'); + throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`); } targetOverride = normalizeTargetValue(value); i += 1; // Skip value @@ -158,7 +152,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags { if (arg.startsWith('--target=')) { const value = arg.slice('--target='.length).trim(); if (!value) { - throw new Error('--target requires a value (claude or droid)'); + throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`); } targetOverride = normalizeTargetValue(value); continue; @@ -204,7 +198,7 @@ export function resolveTargetType( // 3. Check per-profile config if (profileConfig?.target !== undefined) { - return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; + return isPersistedTargetType(profileConfig.target) ? profileConfig.target : 'claude'; } // 4. Default diff --git a/src/targets/target-runtime-compatibility.ts b/src/targets/target-runtime-compatibility.ts new file mode 100644 index 00000000..65fd6024 --- /dev/null +++ b/src/targets/target-runtime-compatibility.ts @@ -0,0 +1,91 @@ +import type { CLIProxyProvider } from '../cliproxy/types'; +import type { ProfileType } from '../types/profile'; +import type { TargetType } from './target-adapter'; + +export interface TargetRuntimeCompatibilityInput { + target: TargetType; + profileType: ProfileType; + cliproxyProvider?: CLIProxyProvider; + cliproxyBridgeProvider?: CLIProxyProvider | null; + isComposite?: boolean; +} + +export interface TargetRuntimeCompatibilityResult { + supported: boolean; + reason?: string; + suggestion?: string; +} + +function unsupported(reason: string, suggestion?: string): TargetRuntimeCompatibilityResult { + return { supported: false, reason, suggestion }; +} + +export function evaluateTargetRuntimeCompatibility( + input: TargetRuntimeCompatibilityInput +): TargetRuntimeCompatibilityResult { + if (input.target === 'claude') { + return { supported: true }; + } + + if (input.target === 'droid') { + if (input.profileType === 'account') { + return unsupported( + 'Factory Droid does not support account-based Claude profiles.', + 'Use a settings-based profile with --target droid instead.' + ); + } + if (input.profileType === 'copilot') { + return unsupported('Factory Droid does not support Copilot profiles.'); + } + return { supported: true }; + } + + if (input.profileType === 'account') { + return unsupported( + 'Codex CLI does not support Claude account-based profiles.', + 'Use native Codex auth with: ccs --target codex' + ); + } + + if (input.profileType === 'copilot') { + return unsupported('Codex CLI does not support Copilot profiles.'); + } + + if (input.profileType === 'default') { + return { supported: true }; + } + + if (input.profileType === 'cliproxy') { + if (input.isComposite) { + return unsupported( + 'Codex CLI currently does not support composite CLIProxy variants.', + 'Use a Codex-only CLIProxy profile or stay on Claude/Droid for composite variants.' + ); + } + if (input.cliproxyProvider !== 'codex') { + return unsupported( + `Codex CLI only supports CLIProxy provider "codex". This profile routes to "${input.cliproxyProvider || 'unknown'}".`, + 'Use: ccs codex --target codex, ccs-codex codex, or stay on Claude/Droid for other providers.' + ); + } + return { supported: true }; + } + + if (input.profileType === 'settings') { + if (input.cliproxyBridgeProvider === 'codex') { + return { supported: true }; + } + if (input.cliproxyBridgeProvider) { + return unsupported( + `Codex CLI only supports CLIProxy Codex bridge profiles. This API profile bridges "${input.cliproxyBridgeProvider}".`, + 'Create a Codex bridge with: ccs api create --cliproxy-provider codex' + ); + } + return unsupported( + 'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.', + 'Use Claude/Droid for generic API profiles, or create a Codex bridge with: ccs api create --cliproxy-provider codex' + ); + } + + return unsupported('Unsupported Codex runtime combination.'); +} diff --git a/tests/npm/cross-platform.test.js b/tests/npm/cross-platform.test.js index ed2c68d8..1382ed88 100644 --- a/tests/npm/cross-platform.test.js +++ b/tests/npm/cross-platform.test.js @@ -131,6 +131,8 @@ describe('cross-platform', () => { assert(packageJson.bin.ccs, 'bin field should specify ccs command'); assert(packageJson.bin['ccs-droid'], 'bin field should specify ccs-droid command'); assert(packageJson.bin.ccsd, 'bin field should specify ccsd command'); + assert(packageJson.bin['ccs-codex'], 'bin field should specify ccs-codex command'); + assert(packageJson.bin.ccsx, 'bin field should specify ccsx command'); assert.notStrictEqual( packageJson.bin['ccs-droid'], packageJson.bin.ccs, @@ -141,10 +143,24 @@ describe('cross-platform', () => { packageJson.bin.ccsd, 'legacy ccsd alias should share the dedicated droid runtime entrypoint' ); + assert.notStrictEqual( + packageJson.bin['ccs-codex'], + packageJson.bin.ccs, + 'ccs-codex should use a dedicated runtime entrypoint' + ); + assert.strictEqual( + packageJson.bin['ccs-codex'], + packageJson.bin.ccsx, + 'ccsx should share the dedicated codex runtime entrypoint' + ); assert( fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])), 'dedicated droid runtime entrypoint should exist' ); + assert( + fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-codex'])), + 'dedicated codex runtime entrypoint should exist' + ); assert(packageJson.scripts, 'package.json should have scripts field'); }); }); diff --git a/tests/unit/targets/ccsd-alias-integration.test.ts b/tests/unit/targets/ccsd-alias-integration.test.ts index 1c5c8f74..2ce98781 100644 --- a/tests/unit/targets/ccsd-alias-integration.test.ts +++ b/tests/unit/targets/ccsd-alias-integration.test.ts @@ -71,13 +71,35 @@ describe('ccsd alias integration', () => { expect(path.basename(argvPath)).toBe('ccs-droid'); }); + it('should preserve ccs-codex symlink basename in argv[1] under node', () => { + if (process.platform === 'win32') { + return; + } + + const argvPath = probeArgvPath('ccs-codex'); + expect(path.basename(argvPath)).toBe('ccs-codex'); + }); + + it('should preserve ccsx symlink basename in argv[1] under node', () => { + if (process.platform === 'win32') { + return; + } + + const argvPath = probeArgvPath('ccsx'); + expect(path.basename(argvPath)).toBe('ccsx'); + }); + it('should preserve extension-style alias basenames for wrapper compatibility', () => { const cmdArgvPath = probeArgvPathDirect('ccsd.cmd'); const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1'); const explicitCmdArgvPath = probeArgvPathDirect('ccs-droid.cmd'); + const codexCmdArgvPath = probeArgvPathDirect('ccs-codex.cmd'); + const codexShortCmdArgvPath = probeArgvPathDirect('ccsx.cmd'); expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd'); expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1'); expect(path.basename(explicitCmdArgvPath)).toBe('ccs-droid.cmd'); + expect(path.basename(codexCmdArgvPath)).toBe('ccs-codex.cmd'); + expect(path.basename(codexShortCmdArgvPath)).toBe('ccsx.cmd'); }); }); diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts new file mode 100644 index 00000000..d74b384b --- /dev/null +++ b/tests/unit/targets/codex-adapter.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from 'bun:test'; + +import { CodexAdapter } from '../../../src/targets/codex-adapter'; + +describe('CodexAdapter', () => { + const adapter = new CodexAdapter(); + + test('supports default, settings, and cliproxy profile types', () => { + expect(adapter.supportsProfileType('default')).toBe(true); + expect(adapter.supportsProfileType('settings')).toBe(true); + expect(adapter.supportsProfileType('cliproxy')).toBe(true); + expect(adapter.supportsProfileType('account')).toBe(false); + expect(adapter.supportsProfileType('copilot')).toBe(false); + }); + + test('passes default-mode args through unchanged', () => { + expect( + adapter.buildArgs('default', ['--search'], { + profileType: 'default', + }) + ).toEqual(['--search']); + }); + + test('translates default-mode reasoning overrides into transient codex config', () => { + const args = adapter.buildArgs('default', ['--search'], { + profileType: 'default', + creds: { + profile: 'default', + baseUrl: '', + apiKey: '', + reasoningOverride: 'medium', + }, + }); + + expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']); + }); + + test('injects transient config overrides for CCS-backed launches', () => { + const args = adapter.buildArgs('codex', ['--search'], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + model: 'gpt-5.4', + reasoningOverride: 'high', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }); + + expect(args).toContain('-c'); + expect(args).toContain('model_provider="ccs_runtime"'); + expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); + expect(args).toContain('model="gpt-5.4"'); + expect(args).toContain('model_reasoning_effort="high"'); + expect(args.at(-1)).toBe('--search'); + }); + + test('fails fast when Codex binary lacks config override support', () => { + expect(() => + adapter.buildArgs('codex', [], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + version: 'codex-cli 0.1.0', + features: [], + }, + }) + ).toThrow(/does not advertise --config overrides/); + }); + + test('rejects native Codex provider-selection flags for CCS-backed launches', () => { + expect(() => + adapter.buildArgs('codex', ['--profile', 'other', '--search'], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }) + ).toThrow(/does not allow --profile\/-p/); + }); + + test('rejects unsupported reasoning override values for CCS-backed launches', () => { + expect(() => + adapter.buildArgs('codex', ['--search'], { + profileType: 'cliproxy', + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + reasoningOverride: 8192, + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }) + ).toThrow(/supports reasoning levels only/); + }); + + 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 defaultEnv = adapter.buildEnv( + { + profile: 'default', + baseUrl: '', + apiKey: '', + }, + 'default' + ); + expect(defaultEnv.CCS_CODEX_API_KEY).toBeUndefined(); + }); +}); diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts new file mode 100644 index 00000000..c392bcf7 --- /dev/null +++ b/tests/unit/targets/codex-detector.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { detectCodexCli, getCodexBinaryInfo } from '../../../src/targets/codex-detector'; + +describe('codex-detector', () => { + let tmpDir: string; + let originalPath: string | undefined; + let originalCodexPath: string | undefined; + const originalPlatform = process.platform; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-detector-test-')); + originalPath = process.env.PATH; + originalCodexPath = process.env.CCS_CODEX_PATH; + process.env.PATH = ''; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + + if (originalPath !== undefined) process.env.PATH = originalPath; + else delete process.env.PATH; + + if (originalCodexPath !== undefined) process.env.CCS_CODEX_PATH = originalCodexPath; + else delete process.env.CCS_CODEX_PATH; + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should prefer CCS_CODEX_PATH when it points to a file', () => { + const fakeCodex = path.join(tmpDir, 'codex'); + fs.writeFileSync(fakeCodex, '#!/bin/sh\necho codex\n'); + process.env.CCS_CODEX_PATH = fakeCodex; + + expect(detectCodexCli()).toBe(fakeCodex); + }); + + it('should return null when CCS_CODEX_PATH points to a directory', () => { + process.env.CCS_CODEX_PATH = tmpDir; + expect(detectCodexCli()).toBeNull(); + }); + + it('should return binary info without throwing when help probing fails', () => { + const fakeCodex = path.join(tmpDir, 'codex'); + fs.writeFileSync(fakeCodex, ''); + process.env.CCS_CODEX_PATH = fakeCodex; + + expect(() => getCodexBinaryInfo()).not.toThrow(); + }); + + it('probes Windows cmd wrappers through the shell so config override support is detected', () => { + const fakeCodex = path.join(tmpDir, 'codex.cmd'); + fs.writeFileSync(fakeCodex, ''); + process.env.CCS_CODEX_PATH = fakeCodex; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => { + return String(command).includes('cmd.exe') && Array.isArray(args) && args.join(' ').includes('--help') + ? 'Codex CLI\n -c, --config \n' + : 'codex-cli 0.118.0-alpha.3'; + }); + + const info = getCodexBinaryInfo(); + + expect(execFileSyncSpy).toHaveBeenCalled(); + expect(info?.needsShell).toBe(true); + expect(info?.features).toContain('config-overrides'); + + execFileSyncSpy.mockRestore(); + }); +}); diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts new file mode 100644 index 00000000..814d4ce5 --- /dev/null +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 20000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('Codex settings bridge launch', () => { + let tmpHome = ''; + let ccsDir = ''; + let settingsPath = ''; + let fakeCodexPath = ''; + let codexArgsLogPath = ''; + let codexEnvLogPath = ''; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-bridge-launch-')); + ccsDir = path.join(tmpHome, '.ccs'); + settingsPath = path.join(ccsDir, 'codex-api.settings.json'); + fakeCodexPath = path.join(tmpHome, 'fake-codex.sh'); + codexArgsLogPath = path.join(tmpHome, 'codex-args.txt'); + codexEnvLogPath = path.join(tmpHome, 'codex-env.txt'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { 'codex-api': settingsPath } }, null, 2) + '\n' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'bridge-token', + ANTHROPIC_MODEL: 'gpt-5.3-codex', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + fakeCodexPath, + `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "codex-cli 0.118.0-alpha.3" + exit 0 +fi + +if [ "$1" = "--help" ]; then + cat <<'EOF' +Codex CLI + -c, --config +EOF + exit 0 +fi + +printf "%s\\n" "$@" > "${codexArgsLogPath}" +printf "%s" "$CCS_CODEX_API_KEY" > "${codexEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeCodexPath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('launches Codex bridge settings profiles and injects runtime overrides', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['codex-api', '--target', 'codex', '--effort', 'high', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('does not support this profile'); + + const argsLog = fs.readFileSync(codexArgsLogPath, 'utf8'); + expect(argsLog).toContain('model_provider="ccs_runtime"'); + expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); + expect(argsLog).toContain('model_reasoning_effort="high"'); + expect(argsLog).toContain('smoke'); + expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); + }); + + it('rejects native Codex profile flags when CCS manages the bridge runtime', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], baseEnv); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('does not allow --profile/-p'); + expect(fs.existsSync(codexArgsLogPath)).toBe(false); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index 046036b6..634c199a 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -13,6 +13,7 @@ import { getRegisteredTargets, ClaudeAdapter, DroidAdapter, + CodexAdapter, } from '../../../src/targets'; describe('target-registry', () => { @@ -20,6 +21,7 @@ describe('target-registry', () => { // Re-register adapters (registry is module-scoped singleton) registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); + registerTarget(new CodexAdapter()); }); it('should register and retrieve claude adapter', () => { @@ -39,6 +41,12 @@ describe('target-registry', () => { expect(adapter.type).toBe('claude'); }); + it('should register and retrieve codex adapter', () => { + const adapter = getTarget('codex'); + expect(adapter.type).toBe('codex'); + expect(adapter.displayName).toBe('Codex CLI'); + }); + it('should throw for unknown target', () => { expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/); }); @@ -46,6 +54,7 @@ describe('target-registry', () => { it('should check target existence', () => { expect(hasTarget('claude')).toBe(true); expect(hasTarget('droid')).toBe(true); + expect(hasTarget('codex')).toBe(true); expect(hasTarget('unknown' as never)).toBe(false); }); @@ -53,6 +62,7 @@ describe('target-registry', () => { const targets = getRegisteredTargets(); expect(targets).toContain('claude'); expect(targets).toContain('droid'); + expect(targets).toContain('codex'); }); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 6e60a177..68299433 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -7,6 +7,7 @@ import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target- describe('resolveTargetType', () => { const originalArgv = process.argv; const originalDroidAliases = process.env.CCS_DROID_ALIASES; + const originalCodexAliases = process.env.CCS_CODEX_ALIASES; const originalTargetAliases = process.env.CCS_TARGET_ALIASES; const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; @@ -18,6 +19,12 @@ describe('resolveTargetType', () => { process.env.CCS_DROID_ALIASES = originalDroidAliases; } + if (originalCodexAliases === undefined) { + delete process.env.CCS_CODEX_ALIASES; + } else { + process.env.CCS_CODEX_ALIASES = originalCodexAliases; + } + if (originalTargetAliases === undefined) { delete process.env.CCS_TARGET_ALIASES; } else { @@ -41,6 +48,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType(['--target', 'droid'])).toBe('droid'); }); + it('should detect --target codex', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'codex'])).toBe('codex'); + }); + it('should detect --target claude', () => { process.argv = ['node', 'ccs']; expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); @@ -56,6 +68,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude'); }); + it('should ignore runtime-only codex target when it appears in persisted profile config', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType([], { target: 'codex' })).toBe('claude'); + }); + it('should prioritize --target flag over profile config', () => { process.argv = ['node', 'ccs']; expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude'); @@ -71,15 +88,31 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect built-in ccs-codex argv[0] alias', () => { + process.argv = ['node', 'ccs-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + + it('should detect built-in ccsx argv[0] alias', () => { + process.argv = ['node', 'ccsx']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should detect custom target aliases from CCS_TARGET_ALIASES', () => { process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid'; process.argv = ['node', 'my-droid']; expect(resolveTargetType([])).toBe('droid'); }); + it('should detect codex aliases from CCS_TARGET_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'codex=codexx,team-codex'; + process.argv = ['node', 'team-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should ignore unsupported targets in CCS_TARGET_ALIASES', () => { - process.env.CCS_TARGET_ALIASES = 'codex=ccsx;droid=ccs-droid-custom'; - process.argv = ['node', 'ccsx']; + process.env.CCS_TARGET_ALIASES = 'not-a-target=mystery-codex;droid=ccs-droid-custom'; + process.argv = ['node', 'mystery-codex']; expect(resolveTargetType([])).toBe('claude'); }); @@ -89,6 +122,12 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect custom argv[0] aliases from CCS_CODEX_ALIASES', () => { + process.env.CCS_CODEX_ALIASES = 'codexx,my-codex'; + process.argv = ['node', 'my-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should merge CCS_TARGET_ALIASES and CCS_DROID_ALIASES', () => { process.env.CCS_TARGET_ALIASES = 'droid=team-droid'; process.env.CCS_DROID_ALIASES = 'legacy-droid'; @@ -100,6 +139,17 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should merge CCS_TARGET_ALIASES and CCS_CODEX_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'codex=team-codex'; + process.env.CCS_CODEX_ALIASES = 'legacy-codex'; + + process.argv = ['node', 'team-codex']; + expect(resolveTargetType([])).toBe('codex'); + + process.argv = ['node', 'legacy-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should ignore invalid custom alias entries', () => { process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,'; process.argv = ['node', '../bad']; @@ -112,6 +162,12 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect internal entry target for codex runtime bins', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should normalize argv[0] and custom aliases case-insensitively', () => { process.env.CCS_DROID_ALIASES = 'DroidCaps'; process.argv = ['node', 'DROIDCAPS']; @@ -128,11 +184,21 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should strip .cmd extension on built-in codex alias', () => { + process.argv = ['node', 'ccs-codex.cmd']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should strip .bat extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.bat']; expect(resolveTargetType([])).toBe('droid'); }); + it('should strip .bat extension on codex shortcut alias', () => { + process.argv = ['node', 'ccsx.bat']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should strip .ps1 extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.ps1']; expect(resolveTargetType([])).toBe('droid'); @@ -153,6 +219,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should handle full path argv[0] for ccs-codex', () => { + process.argv = ['node', '/usr/local/bin/ccs-codex']; + expect(resolveTargetType([])).toBe('codex'); + }); + it('should prioritize --target over argv[0]', () => { process.argv = ['node', 'ccsd']; expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); @@ -176,8 +247,10 @@ describe('resolveTargetType', () => { }); it('should keep reserved command names authoritative', () => { - process.env.CCS_TARGET_ALIASES = 'claude=ccs,ccs-droid,ccsd;droid=mydroid'; + process.env.CCS_TARGET_ALIASES = + 'claude=ccs,ccs-droid,ccsd,ccs-codex,ccsx;droid=mydroid;codex=mycodex'; process.env.CCS_DROID_ALIASES = 'ccs,ccs-droid,ccsd,legacy-droid'; + process.env.CCS_CODEX_ALIASES = 'ccs,ccs-codex,ccsx,legacy-codex'; process.argv = ['node', 'ccs']; expect(resolveTargetType([])).toBe('claude'); @@ -188,11 +261,23 @@ describe('resolveTargetType', () => { process.argv = ['node', 'ccsd']; expect(resolveTargetType([])).toBe('droid'); + process.argv = ['node', 'ccs-codex']; + expect(resolveTargetType([])).toBe('codex'); + + process.argv = ['node', 'ccsx']; + expect(resolveTargetType([])).toBe('codex'); + process.argv = ['node', 'mydroid']; expect(resolveTargetType([])).toBe('droid'); process.argv = ['node', 'legacy-droid']; expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'mycodex']; + expect(resolveTargetType([])).toBe('codex'); + + process.argv = ['node', 'legacy-codex']; + expect(resolveTargetType([])).toBe('codex'); }); it('should throw for invalid --target value', () => { diff --git a/tests/unit/targets/target-runtime-compatibility.test.ts b/tests/unit/targets/target-runtime-compatibility.test.ts new file mode 100644 index 00000000..cc169ec6 --- /dev/null +++ b/tests/unit/targets/target-runtime-compatibility.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; + +import { evaluateTargetRuntimeCompatibility } from '../../../src/targets/target-runtime-compatibility'; + +describe('evaluateTargetRuntimeCompatibility', () => { + test('supports native Codex default sessions', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'default', + }).supported + ).toBe(true); + }); + + test('supports Codex CLIProxy provider sessions only for provider codex', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'codex', + isComposite: false, + }).supported + ).toBe(true); + + const unsupported = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'gemini', + isComposite: false, + }); + expect(unsupported.supported).toBe(false); + expect(unsupported.reason).toMatch(/only supports CLIProxy provider "codex"/); + }); + + test('rejects composite CLIProxy variants on Codex target', () => { + const compatibility = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cliproxy', + cliproxyProvider: 'codex', + isComposite: true, + }); + + expect(compatibility.supported).toBe(false); + expect(compatibility.reason).toMatch(/does not support composite CLIProxy variants/); + }); + + test('supports only Codex bridge API profiles on Codex target', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'settings', + cliproxyBridgeProvider: 'codex', + }).supported + ).toBe(true); + + const compatibility = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'settings', + cliproxyBridgeProvider: 'gemini', + }); + expect(compatibility.supported).toBe(false); + expect(compatibility.reason).toMatch(/only supports CLIProxy Codex bridge profiles/); + }); + + test('rejects account and copilot profiles on Codex target', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'account', + }).supported + ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'copilot', + }).supported + ).toBe(false); + }); +}); From f9c1238483b0c91d701547c9ad330261a2b51fbb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 19:03:22 -0400 Subject: [PATCH 02/10] feat(profiles): expose codex runtime across surfaces - update CLI, API, route, and dashboard surfaces to recognize the Codex runtime target - normalize persisted codex targets back to claude so runtime-only behavior stays truthful - add regression coverage for help text, route parsing, and profile storage normalization Refs #773 --- src/api/services/profile-lifecycle-service.ts | 5 +- src/api/services/profile-reader.ts | 5 +- src/channels/official-channels-runtime.ts | 2 +- src/commands/api-command/create-command.ts | 11 ++ src/commands/api-command/help.ts | 4 + src/commands/api-command/shared.ts | 9 +- src/commands/cliproxy/variant-subcommand.ts | 11 +- src/commands/help-command.ts | 13 +- src/web-server/routes/profile-routes.ts | 17 ++- src/web-server/routes/route-helpers.ts | 5 +- src/web-server/routes/variant-routes.ts | 5 +- tests/unit/api/profile-reader.test.ts | 138 ++++++++++++++++++ tests/unit/commands/api-command-args.test.ts | 7 + .../commands/cliproxy-variant-args.test.ts | 7 + .../unit/commands/help-command-parity.test.ts | 22 +++ .../web-server/target-parse-routes.test.ts | 2 + .../provider-editor/provider-info-tab.tsx | 17 +++ ui/src/lib/model-catalogs.ts | 5 +- ui/src/lib/support-updates-catalog.ts | 73 ++++++++- 19 files changed, 332 insertions(+), 26 deletions(-) create mode 100644 tests/unit/api/profile-reader.test.ts diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 815d253e..e54a3e92 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { Config, Settings } from '../../types'; import type { TargetType } from '../../targets/target-adapter'; +import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector'; import { isSensitiveKey } from '../../utils/sensitive-keys'; @@ -29,7 +30,7 @@ const SETTINGS_FILE_SUFFIX = '.settings.json'; const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__'; function parseTargetValue(value: unknown): TargetType | null { - if (value === 'claude' || value === 'droid') { + if (isPersistedTargetType(value)) { return value; } return null; @@ -359,7 +360,7 @@ export function importApiProfileBundle( if (input.profile.target !== undefined && bundleTarget === null) { return { success: false, - error: 'Invalid bundle profile target. Expected: claude or droid.', + error: `Invalid bundle profile target. Expected: ${getPersistedTargetChoices()}.`, }; } diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 4bb99b31..a18f04c2 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -10,14 +10,13 @@ import { loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; import { expandPath } from '../../utils/helpers'; import type { TargetType } from '../../targets/target-adapter'; +import { isPersistedTargetType } from '../../targets/target-metadata'; import type { Settings } from '../../types/config'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge'; -const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); - function sanitizeTarget(target: unknown): TargetType { - if (typeof target === 'string' && VALID_TARGETS.has(target as TargetType)) { + if (isPersistedTargetType(target)) { return target as TargetType; } return 'claude'; diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index f708f66e..df5c97b1 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -292,7 +292,7 @@ export function getOfficialChannelsStateScopeMessage(): string { } export function getOfficialChannelsSupportMessage(): string { - return 'Works only for native Claude default/account sessions. It does not apply to API, OAuth, or Droid targets such as `ccs glm`, `ccs gemini`, `ccs codex`, or `ccs --target droid`.'; + return 'Works only for native Claude default/account sessions. It does not apply to API, OAuth, or non-Claude targets such as `ccs glm`, `ccs gemini`, `ccs codex`, `ccs --target droid`, or `ccs --target codex`.'; } export function getOfficialChannelsAccountStatusCaveat(): string { diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 9583b8f8..ccdbe5e6 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -417,6 +417,17 @@ export async function handleApiCreateCommand(args: string[]): Promise { ` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}` ); } + if (cliproxyProvider === 'codex') { + console.log( + ` ${color(`ccs ${result.name} --target codex "your prompt"`, 'command')} ${dim('# native Codex runtime')}` + ); + console.log( + ` ${color(`ccs-codex ${result.name} "your prompt"`, 'command')} ${dim('# explicit Codex alias')}` + ); + console.log( + ` ${color(`ccsx ${result.name} "your prompt"`, 'command')} ${dim('# short alias')}` + ); + } console.log(''); console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy')); return; diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index 37ddfccf..78c6dcc4 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -107,6 +107,10 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P writeLine( ` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}` ); + writeLine(` ${color('ccs api create codex-api --cliproxy-provider codex', 'command')}`); + writeLine( + ` ${color('ccs codex-api --target codex', 'command')} ${dim('# runtime-only native Codex launch')}` + ); writeLine(''); writeLine(` ${dim('# Create with name')}`); writeLine(` ${color('ccs api create myapi', 'command')}`); diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index 06ae02c1..37dfe35f 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -1,5 +1,6 @@ import type { ModelMapping } from '../../api/services'; import type { TargetType } from '../../targets/target-adapter'; +import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; import { applyExtendedContextSuffix, hasExtendedContextSuffix, @@ -108,7 +109,7 @@ export function extractPositionalArgs(args: string[]): string[] { function parseTargetValue(value: string): TargetType | null { const normalized = value.trim().toLowerCase(); - if (normalized === 'claude' || normalized === 'droid') { + if (isPersistedTargetType(normalized)) { return normalized; } return null; @@ -132,7 +133,7 @@ export function parseOptionalTargetFlag( if (!target) { return { remainingArgs: extracted.remainingArgs, - errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`], + errors: [`Invalid --target value "${extracted.value}". Use: ${getPersistedTargetChoices()}`], }; } @@ -251,7 +252,9 @@ export function parseApiCommandArgs( (value) => { const target = parseTargetValue(value); if (!target) { - result.errors.push(`Invalid --target value "${value}". Use: claude or droid`); + result.errors.push( + `Invalid --target value "${value}". Use: ${getPersistedTargetChoices()}` + ); return; } result.target = target; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 44d69d2b..99dcef7f 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -13,6 +13,7 @@ import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detec import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; import type { TargetType } from '../../targets/target-adapter'; +import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; @@ -42,7 +43,7 @@ interface CliproxyProfileArgs { function parseTargetValue(rawValue: string): TargetType | null { const normalized = rawValue.trim().toLowerCase(); - if (normalized === 'claude' || normalized === 'droid') { + if (isPersistedTargetType(normalized)) { return normalized; } return null; @@ -73,7 +74,9 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs { i += 1; const parsedTarget = parseTargetValue(rawValue); if (!parsedTarget) { - result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + result.errors.push( + `Invalid --target value "${rawValue}". Use: ${getPersistedTargetChoices()}` + ); } else { result.target = parsedTarget; } @@ -82,7 +85,9 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs { const rawValue = arg.slice('--target='.length); const parsedTarget = parseTargetValue(rawValue); if (!parsedTarget) { - result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + result.errors.push( + `Invalid --target value "${rawValue}". Use: ${getPersistedTargetChoices()}` + ); } else { result.target = parsedTarget; } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6eca9227..11d7dad5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -391,7 +391,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Flags', [ ['--config-dir ', 'Use custom CCS config directory'], - ['--target ', 'Target CLI: claude (default), droid'], + ['--target ', 'Target CLI: claude (default), droid, codex (runtime-only)'], ['-h, --help', 'Show this help message'], ['-v, --version', 'Show version and installation info'], ['-sc, --shell-completion', 'Install shell auto-completion'], @@ -405,6 +405,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); [ ['ccs-droid [args]', 'Explicit Droid runtime alias'], ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], + ['ccs-codex [args]', 'Explicit Codex runtime alias'], + ['ccsx [args]', 'Short alias for: ccs-codex [args]'], ], writeLine ); @@ -416,6 +418,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], ['ccs-droid glm', 'Same as above (explicit alias)'], ['ccsd glm', 'Legacy shortcut for ccs-droid'], + ['ccs --target codex', 'Open a native Codex session with your existing ~/.codex setup'], + ['ccs-codex', 'Same as above (explicit Codex alias)'], + ['ccsx', 'Short alias for ccs-codex'], + ['ccs codex --target codex', 'Run built-in CLIProxy Codex on native Codex CLI'], + [ + 'ccs api create codex-api --cliproxy-provider codex', + 'Create a routed API bridge that can also run on Codex', + ], + ['ccs codex-api --target codex', 'Run a Codex bridge profile on native Codex CLI'], ['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'], ['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'], [ diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 800ca5c1..d640d04e 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -23,6 +23,7 @@ import { validateApiName, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; +import { getPersistedTargetChoices } from '../../targets/target-metadata'; import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers'; @@ -100,7 +101,7 @@ router.post('/cliproxy-bridge', (req: Request, res: Response): void => { const target = parseTarget(shape.payload.target); if (shape.payload.target !== undefined && target === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } @@ -155,7 +156,7 @@ router.post('/', (req: Request, res: Response): void => { const parsedTarget = parseTarget(target); if (target !== undefined && parsedTarget === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } if (providerHint !== undefined && parsedProvider === null) { @@ -265,7 +266,7 @@ router.post('/orphans/register', (req: Request, res: Response): void => { const force = payload.force === true; if (payload.target !== undefined && target === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } @@ -306,7 +307,7 @@ router.post('/:name/copy', (req: Request, res: Response): void => { return; } if (shape.payload.target !== undefined && target === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } @@ -357,7 +358,7 @@ router.post('/import', (req: Request, res: Response): void => { const target = parseTarget(shape.payload.target); if (shape.payload.target !== undefined && target === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } @@ -368,7 +369,9 @@ router.post('/import', (req: Request, res: Response): void => { } const bundleTarget = (bundle as { profile?: { target?: unknown } }).profile?.target; if (bundleTarget !== undefined && parseTarget(bundleTarget) === null) { - res.status(400).json({ error: 'Invalid bundle profile target. Expected: claude or droid' }); + res.status(400).json({ + error: `Invalid bundle profile target. Expected: ${getPersistedTargetChoices()}`, + }); return; } @@ -418,7 +421,7 @@ router.put('/:name', (req: Request, res: Response): void => { const parsedTarget = parseTarget(target); if (target !== undefined && parsedTarget === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } if (providerHint !== undefined && parsedProvider === null) { diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 7b8f785e..b45f762a 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -18,6 +18,7 @@ import { import type { CLIProxyProvider } from '../../cliproxy/types'; import type { Config, Settings } from '../../types/config'; import type { TargetType } from '../../targets/target-adapter'; +import { isPersistedTargetType } from '../../targets/target-metadata'; import { ValidationError } from '../../errors/error-types'; /** Model mapping for API profiles */ @@ -438,7 +439,7 @@ export function validateFilePath(filePath: string): { } /** - * Parse and validate a target param (claude/droid). Returns null if invalid/absent. + * Parse and validate a persisted target param. Returns null if invalid/absent. * Shared by profile-routes and variant-routes. */ export function parseTarget(rawTarget: unknown): TargetType | null { @@ -451,7 +452,7 @@ export function parseTarget(rawTarget: unknown): TargetType | null { } const normalized = rawTarget.trim().toLowerCase(); - if (normalized === 'claude' || normalized === 'droid') { + if (isPersistedTargetType(normalized)) { return normalized; } diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 483bf11d..c67d3858 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { getPersistedTargetChoices } from '../../targets/target-metadata'; import { parseTarget } from './route-helpers'; import { createVariant, @@ -55,7 +56,7 @@ router.post('/', (req: Request, res: Response): void => { const parsedTarget = parseTarget(req.body.target); if (req.body.target !== undefined && parsedTarget === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } @@ -178,7 +179,7 @@ router.put('/:name', (req: Request, res: Response): void => { const parsedTarget = parseTarget(req.body.target); if (req.body.target !== undefined && parsedTarget === null) { - res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + res.status(400).json({ error: `Invalid target. Expected: ${getPersistedTargetChoices()}` }); return; } diff --git a/tests/unit/api/profile-reader.test.ts b/tests/unit/api/profile-reader.test.ts new file mode 100644 index 00000000..f677b2a1 --- /dev/null +++ b/tests/unit/api/profile-reader.test.ts @@ -0,0 +1,138 @@ +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 { listApiProfiles } from '../../../src/api/services/profile-reader'; +import { runWithScopedConfigDir, setGlobalConfigDir } from '../../../src/utils/config-manager'; + +describe('profile reader target sanitization', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + let originalUnifiedMode: string | undefined; + + function getScopedCcsDir(): string { + return path.join(tempHome, '.ccs'); + } + + async function runInScopedCcsDir(fn: () => T): Promise { + return await runWithScopedConfigDir(getScopedCcsDir(), fn); + } + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-reader-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + delete process.env.CCS_UNIFIED_CONFIG; + setGlobalConfigDir(undefined); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + + if (originalUnifiedMode === undefined) { + delete process.env.CCS_UNIFIED_CONFIG; + } else { + process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + } + + setGlobalConfigDir(undefined); + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('normalizes legacy stored codex targets back to claude for profiles and variants', async () => { + const ccsDir = getScopedCcsDir(); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { demo: '~/.ccs/demo.settings.json' }, + profile_targets: { demo: 'codex' }, + cliproxy: { + routed: { + provider: 'codex', + settings: '~/.ccs/routed.settings.json', + target: 'codex', + }, + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'demo.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const result = await runInScopedCcsDir(() => listApiProfiles()); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]?.target).toBe('claude'); + expect(result.variants).toHaveLength(1); + expect(result.variants[0]?.target).toBe('claude'); + }); + + it('normalizes unified stored codex targets back to claude for profiles and variants', async () => { + const ccsDir = getScopedCcsDir(); + fs.mkdirSync(ccsDir, { recursive: true }); + process.env.CCS_UNIFIED_CONFIG = '1'; + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'profiles:', + ' demo:', + ' type: api', + ' settings: ~/.ccs/demo.settings.json', + ' target: codex', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: []', + ' variants:', + ' routed:', + ' provider: codex', + ' settings: ~/.ccs/routed.settings.json', + ' target: codex', + '', + ].join('\n'), + 'utf8' + ); + fs.writeFileSync( + path.join(ccsDir, 'demo.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const result = await runInScopedCcsDir(() => listApiProfiles()); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]?.target).toBe('claude'); + expect(result.variants).toHaveLength(1); + expect(result.variants[0]?.target).toBe('claude'); + }); +}); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 19727ec2..1f5dee30 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -75,6 +75,13 @@ describe('api-command arg parser', () => { ]); }); + test('rejects runtime-only codex as a persisted API target value', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'codex']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + }); + test('collects missing-value error for --target with no value', () => { const parsed = parseApiCommandArgs(['my-api', '--target']); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts index 1d3ad82e..a810bceb 100644 --- a/tests/unit/commands/cliproxy-variant-args.test.ts +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -26,6 +26,13 @@ describe('cliproxy variant arg parser', () => { expect(parsed.errors).toEqual(['Missing value for --target']); }); + test('rejects runtime-only codex as a persisted variant target value', () => { + const parsed = parseProfileArgs(['variant-a', '--target', 'codex']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + }); + test('uses last --target value when repeated', () => { const parsed = parseProfileArgs(['variant-a', '--target', 'claude', '--target=droid']); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 04efd788..41619e6b 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -99,6 +99,18 @@ describe('help command parity', () => { expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true); }); + test('root help documents native Codex runtime alias and runtime-only scope', async () => { + const lines: string[] = []; + await handleHelpCommand((line) => lines.push(line)); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('ccs-codex [args]')).toBe(true); + expect(rendered.includes('ccsx [args]')).toBe(true); + expect(rendered.includes('ccs --target codex')).toBe(true); + expect(rendered.includes('ccs codex-api --target codex')).toBe(true); + expect(rendered.includes('codex (runtime-only)')).toBe(true); + }); + test('api help documents create-time Claude [1m] flags and entitlement warning', async () => { const lines: string[] = []; await showApiCommandHelp((line) => lines.push(line)); @@ -113,4 +125,14 @@ describe('help command parity', () => { true ); }); + + test('api help documents Codex bridge runtime launch separately from persisted targets', async () => { + const lines: string[] = []; + await showApiCommandHelp((line) => lines.push(line)); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('ccs api create codex-api --cliproxy-provider codex')).toBe(true); + expect(rendered.includes('ccs codex-api --target codex')).toBe(true); + expect(rendered.includes('Default target: claude or droid (create)')).toBe(true); + }); }); diff --git a/tests/unit/web-server/target-parse-routes.test.ts b/tests/unit/web-server/target-parse-routes.test.ts index 2852a749..b0446dab 100644 --- a/tests/unit/web-server/target-parse-routes.test.ts +++ b/tests/unit/web-server/target-parse-routes.test.ts @@ -13,8 +13,10 @@ describe('route target parsing', () => { it('returns null for invalid target values', () => { expect(parseProfileTarget('glm')).toBeNull(); + expect(parseProfileTarget('codex')).toBeNull(); expect(parseProfileTarget('')).toBeNull(); expect(parseVariantTarget('factory')).toBeNull(); + expect(parseVariantTarget('codex')).toBeNull(); expect(parseVariantTarget(' ')).toBeNull(); }); diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index bc6c8f5f..8cde0f8c 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -28,6 +28,7 @@ export function ProviderInfoTab({ }: ProviderInfoTabProps) { const resolvedTarget = defaultTarget || 'claude'; const isDroidTarget = resolvedTarget === 'droid'; + const isCodexProvider = provider === 'codex'; return ( @@ -88,6 +89,22 @@ export function ProviderInfoTab({

Quick Usage

+ {isCodexProvider && ( + <> + + + + + )} compareGeminiVersions(right.info.version, left.info.version))[0]?.model; } -export function resolveCatalogModelId(modelId: string, availableModels: CatalogAvailableModel[] = []): string { +export function resolveCatalogModelId( + modelId: string, + availableModels: CatalogAvailableModel[] = [] +): string { const normalizedModelId = normalizeModelId(modelId); const liveGeminiModelId = resolveGeminiPreviewModelId(normalizedModelId, availableModels); if (liveGeminiModelId) return liveGeminiModelId; diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 7a7097c1..bd3c8066 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -55,6 +55,51 @@ export const SUPPORT_SCOPE_LABELS: Record = { }; export const SUPPORT_NOTICES: SupportNotice[] = [ + { + id: 'codex-target-runtime-support', + title: 'Native Codex runtime support is live', + summary: + 'Codex now participates as a first-class runtime target through ccs-codex, ccsx, or --target codex.', + primaryAction: + 'Use Codex as a runtime target for native Codex sessions and Codex-routed CLIProxy flows.', + publishedAt: '2026-03-28', + status: 'new', + scopes: ['target', 'cliproxy', 'api-profiles'], + entryIds: ['codex-target', 'codex-cliproxy'], + highlights: [ + 'Use ccs-codex or ccsx for native Codex runs.', + 'Built-in Codex and Codex bridge profiles can run on native Codex with --target codex.', + 'Saved default targets for API profiles and variants remain claude or droid.', + ], + actions: [ + { + id: 'copy-codex-alias-command', + label: 'Open native Codex', + description: 'Launch Codex through the explicit CCS runtime alias.', + type: 'command', + command: 'ccs-codex', + }, + { + id: 'copy-codex-provider-command', + label: 'Run built-in Codex on Codex', + description: 'Use the built-in Codex provider with native Codex runtime.', + type: 'command', + command: 'ccs codex --target codex "your prompt"', + }, + { + id: 'open-cliproxy-codex', + label: 'Open Codex provider settings', + description: 'Review Codex provider and bridge flows in the dashboard.', + type: 'route', + path: '/cliproxy', + }, + ], + routes: [ + { label: 'CLIProxy', path: '/cliproxy' }, + { label: 'API Profiles', path: '/providers' }, + ], + commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex "your prompt"'], + }, { id: 'droid-target-support', title: 'Factory Droid support is live', @@ -185,6 +230,27 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ commands: ['ccs-droid glm', 'ccs km --target droid', 'ccs codex --target droid'], notes: 'Use ccs-droid as the explicit runtime alias. Legacy ccsd still works.', }, + { + id: 'codex-target', + name: 'Codex CLI', + scope: 'target', + status: 'new', + summary: + 'First-class runtime target for native Codex sessions and Codex-routed CLIProxy flows.', + pillars: { + baseUrl: + 'Native ~/.codex config for default mode, transient -c overrides for CCS-backed routes', + auth: 'Native Codex auth for default mode, env_key injection for CCS-backed routes', + model: 'Native Codex config or routed Codex model mapping from CLIProxy', + }, + routes: [ + { label: 'CLIProxy', path: '/cliproxy' }, + { label: 'API Profiles', path: '/providers' }, + ], + commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'], + notes: + 'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.', + }, { id: 'codex-cliproxy', name: 'Codex via CLIProxy', @@ -200,7 +266,12 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ { label: 'CLIProxy', path: '/cliproxy' }, { label: 'Control Panel', path: '/cliproxy/control-panel' }, ], - commands: ['ccs codex', 'ccs cliproxy create mycodex --provider codex'], + commands: [ + 'ccs codex', + 'ccs codex --target codex', + 'ccs cliproxy create mycodex --provider codex', + 'ccs api create codex-api --cliproxy-provider codex', + ], }, { id: 'gemini-cliproxy', From da4bb29fd71434f2e9179a34c37257c2c5e9df30 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 19:03:54 -0400 Subject: [PATCH 03/10] docs(codex): document runtime target support - document the ccs-codex and ccsx runtime aliases - capture the supported v1 matrix and bridge behavior for native Codex runs Refs #773 --- README.md | 32 ++++- docs/codebase-summary.md | 24 +++- docs/system-architecture/target-adapters.md | 130 +++++++++++++++++++- 3 files changed, 175 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a2f11dbd..77ac455b 100644 --- a/README.md +++ b/README.md @@ -201,17 +201,21 @@ Built-in Droid runtime aliases are installed with the package: ```bash ccs-droid glm # explicit alias ccsd glm # legacy shortcut +ccs-codex # explicit Codex alias +ccsx # short Codex alias ``` Need additional alias names? First create the matching symlink or another launcher that preserves the invoked basename, then map that name with `CCS_TARGET_ALIASES` (preferred) or legacy -`CCS_DROID_ALIASES`: +target-specific env vars: ```bash ln -s "$(command -v ccs)" /usr/local/bin/mydroid -CCS_TARGET_ALIASES='droid=mydroid' +ln -s "$(command -v ccs)" /usr/local/bin/mycodex +CCS_TARGET_ALIASES='droid=mydroid;codex=mycodex' # Legacy fallback still supported: CCS_DROID_ALIASES='mydroid' +CCS_CODEX_ALIASES='mycodex' ``` For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint @@ -239,6 +243,30 @@ flag and warns about duplicates. Dashboard parity: `ccs config` -> `Factory Droid` +### Native Codex Runtime (runtime-only in v1) + +CCS can launch native Codex as a first-class runtime target without rewriting your +`~/.codex/config.toml` on every run. CCS uses transient `codex -c key=value` overrides for +Codex-routed sessions and leaves your existing Codex home/config in place. + +Supported in v1: + +```bash +ccs --target codex # native Codex default session +ccs-codex # explicit Codex alias +ccsx # short alias +ccs codex --target codex # built-in CLIProxy Codex on native Codex +ccs api create codex-api --cliproxy-provider codex +ccs codex-api --target codex # Codex bridge profile on native Codex +``` + +Not supported in v1: +- Claude account profiles on Codex target +- Copilot profiles on Codex target +- Generic API profiles that are not Codex-routed CLIProxy bridges +- Non-Codex CLIProxy providers on Codex target +- Composite CLIProxy variants on Codex target + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 2f35dd80..acc97821 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # CCS Codebase Summary -Last Updated: 2026-03-24 +Last Updated: 2026-03-28 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, and Official Claude Channels runtime support. +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support. ## Repository Structure @@ -35,6 +35,9 @@ The main CLI is organized into domain-specific modules with barrel exports. ``` src/ ├── ccs.ts # Main entry point & profile execution flow +├── bin/ # Dedicated runtime entrypoints +│ ├── droid-runtime.ts # argv[0] shim for ccs-droid / ccsd +│ └── codex-runtime.ts # argv[0] shim for ccs-codex / ccsx ├── types/ # TypeScript type definitions │ ├── index.ts # Barrel export (aggregates all types) │ ├── cli.ts # CLI types (ParsedArgs, ExitCode) @@ -67,8 +70,12 @@ src/ │ ├── target-adapter.ts # TargetAdapter interface contract │ ├── target-registry.ts # Registry for runtime adapter lookup │ ├── target-resolver.ts # Resolution logic (flag > config > argv[0]) +│ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists +│ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations │ ├── claude-adapter.ts # Claude Code CLI implementation │ ├── droid-adapter.ts # Factory Droid CLI implementation +│ ├── codex-adapter.ts # Native Codex CLI implementation +│ ├── codex-detector.ts # Codex binary detection and capability probing │ ├── droid-detector.ts # Droid binary detection & version checks │ └── droid-config-manager.ts # ~/.factory/settings.json management │ @@ -203,7 +210,7 @@ src/ | Category | Directories | Purpose | |----------|-------------|---------| | Core | `commands/`, `errors/` | CLI commands, error handling | -| Targets | `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, extensible) | +| Targets | `bin/`, `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, Codex CLI, extensible) | | Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | | Config | `config/`, `types/` | Configuration & type definitions | | Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations plus retained legacy transformer internals | @@ -238,6 +245,17 @@ src/ - Runtime contract lives in `src/channels/official-channels-runtime.ts` and is consumed from `src/ccs.ts`, `src/commands/config-channels-command.ts`, and `src/web-server/routes/channels-routes.ts`. - Canonical config lives under `channels.*` in `~/.ccs/config.yaml`; legacy `discord_channels.*` remains read-compatible only when canonical fields are absent. + +### Native Codex Runtime Target + +- Runtime aliases: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts` and `src/targets/target-resolver.ts`. +- 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. +- Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection. +- Supported Codex flows in v1: + - `default` + - CLIProxy provider `codex` + - settings/API profiles only when they resolve to a Codex CLIProxy bridge - Telegram and Discord bot tokens are intentionally written into Claude-managed machine state under `~/.claude/channels//.env`, unless the official `*_STATE_DIR` environment override redirects that channel elsewhere. - iMessage is tokenless, macOS-only, and still depends on Claude-side plugin install plus OS permissions. - Auto-enable is gated on Bun availability, verified Claude Code v2.1.80+, verified `claude.ai` auth, native Claude `default/account` sessions, and per-channel setup readiness. diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 23a29a97..a390cc23 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -1,6 +1,6 @@ # Target Adapters -Last Updated: 2026-02-16 +Last Updated: 2026-03-28 Detailed documentation of the target adapter pattern and implementations. @@ -20,8 +20,8 @@ Each CLI target implements the `TargetAdapter` contract: ```typescript export interface TargetAdapter { - readonly type: TargetType; // 'claude' | 'droid' - readonly displayName: string; // "Claude Code" | "Factory Droid" + readonly type: TargetType; // 'claude' | 'droid' | 'codex' + readonly displayName: string; // "Claude Code" | "Factory Droid" | "Codex CLI" /** Detect if the target CLI binary exists on system */ detectBinary(): TargetBinaryInfo | null; @@ -30,7 +30,15 @@ export interface TargetAdapter { prepareCredentials(creds: TargetCredentials): Promise; /** Build spawn arguments for the target CLI */ - buildArgs(profile: string, userArgs: string[]): string[]; + buildArgs( + profile: string, + userArgs: string[], + options?: { + creds?: TargetCredentials; + profileType?: ProfileType; + binaryInfo?: TargetBinaryInfo; + } + ): string[]; /** Build environment variables for the target CLI */ buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv; @@ -46,7 +54,7 @@ export interface TargetAdapter { ### Type Definitions ```typescript -export type TargetType = 'claude' | 'droid'; +export type TargetType = 'claude' | 'droid' | 'codex'; export interface TargetCredentials { baseUrl: string; // API endpoint @@ -59,6 +67,8 @@ export interface TargetCredentials { export interface TargetBinaryInfo { path: string; // Full path to binary needsShell: boolean; // Windows .cmd/.bat/.ps1? + version?: string; // Optional version string + features?: readonly string[]; // Capability probes } ``` @@ -73,8 +83,10 @@ CCS resolves which adapter to use via priority-ordered checks: ``` 1. --target flag (CLI argument) — highest priority └─ ccs --target droid glm + └─ ccs --target codex 2. Per-profile config (from ~/.ccs/config.yaml or settings.json) + └─ persisted targets are currently only `claude` and `droid` └─ profiles: glm: target: droid @@ -82,6 +94,8 @@ CCS resolves which adapter to use via priority-ordered checks: 3. argv[0] detection (runtime alias pattern) — binary name mapping └─ ccs-droid (explicit alias) → droid └─ ccsd (legacy shortcut) → droid + └─ ccs-codex (explicit alias) → codex + └─ ccsx (short alias) → codex └─ ccs (regular command) → default 4. Fallback: 'claude' — lowest priority @@ -105,6 +119,7 @@ export function resolveTargetType( // 2. Check profile config if (profileConfig?.target) { + // Persisted targets intentionally exclude runtime-only codex. return profileConfig.target; } @@ -380,6 +395,103 @@ CCS_TARGET_ALIASES=droid=mydroid --- +## Codex Adapter + +### Implementation + +The Codex adapter keeps CCS-backed Codex launches transient. It does not rewrite +`~/.codex/config.toml`. Instead it: + +- passes through native default Codex sessions unchanged +- probes the installed Codex binary for `--config ` support +- injects CCS-backed provider credentials through temporary `-c` overrides +- stores the routed API key only in process env via `CCS_CODEX_API_KEY` + +```typescript +// src/targets/codex-adapter.ts + +export class CodexAdapter implements TargetAdapter { + readonly type: TargetType = 'codex'; + readonly displayName = 'Codex CLI'; + + detectBinary(): TargetBinaryInfo | null { + return getCodexBinaryInfo(); + } + + async prepareCredentials(_creds: TargetCredentials): Promise { + // No file writes. Codex uses transient -c overrides plus env_key injection. + } + + buildArgs(profile: string, userArgs: string[], options?: BuildOptions): string[] { + if ((options?.profileType || 'default') === 'default') { + return userArgs; + } + + if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + throw new Error('Upgrade Codex before using CCS-backed Codex profiles.'); + } + + return [ + '-c', + 'model_provider=\"ccs_runtime\"', + '-c', + 'model_providers.ccs_runtime.base_url=\"http://127.0.0.1:8317/api/provider/codex\"', + '-c', + 'model_providers.ccs_runtime.env_key=\"CCS_CODEX_API_KEY\"', + '-c', + 'model_providers.ccs_runtime.wire_api=\"responses\"', + ...userArgs, + ]; + } + + buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv { + const env = { ...stripAnthropicEnv(process.env) }; + if (profileType !== 'default') { + env['CCS_CODEX_API_KEY'] = creds.apiKey; + } + return env; + } +} +``` + +### Support Matrix + +Codex is a real runtime target, but it is intentionally narrower than Claude or Droid in v1: + +| Profile Type | Codex Target | Notes | +|--------------|--------------|-------| +| `default` | Yes | Uses existing native Codex auth/config | +| `cliproxy` provider=`codex` | Yes | Routed through CLIProxy Codex Responses bridge | +| `cliproxy` composite | No | Not proven native-Codex-safe | +| `settings` with Codex bridge metadata | Yes | Only when the API profile resolves to a Codex CLIProxy bridge | +| `settings` generic API profile | No | Claude/Droid only | +| `account` | No | Claude-only account isolation concept | +| `copilot` | No | Not a native Codex provider path | + +### Runtime Alias Pattern + +```bash +# Built-in package bin aliases +ccs-codex +→ Target: codex (forced by runtime alias) + +ccsx codex +→ Target: codex (forced by runtime alias) +→ codex ...args +``` + +Runtime aliases can also be extended with `CCS_TARGET_ALIASES` or legacy +`CCS_CODEX_ALIASES` after creating a matching launcher: + +```bash +ln -s /path/to/ccs /path/to/mycodex +CCS_TARGET_ALIASES='codex=mycodex' +# Legacy fallback: +CCS_CODEX_ALIASES='mycodex' +``` + +--- + ## Registry and Lookup The target registry is a simple map-based store for adapters: @@ -415,6 +527,7 @@ At startup, adapters self-register: registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); +registerTarget(new CodexAdapter()); ``` --- @@ -522,7 +635,7 @@ export class MyAiAdapter implements TargetAdapter { ```typescript // src/targets/target-adapter.ts -export type TargetType = 'claude' | 'droid' | 'myai'; +export type TargetType = 'claude' | 'droid' | 'codex' | 'myai'; ``` ### 3. Register in ccs.ts @@ -621,8 +734,13 @@ ccs --target claude help # Test Droid adapter (if installed) ccs --target droid help +# Test Codex adapter (if installed) +ccs --target codex +ccs-codex + # Test argv[0] detection ccs-droid help +ccsx ``` --- From 8c5da9f9e878196356e33a799b921b67e3396252 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 19:57:54 -0400 Subject: [PATCH 04/10] feat: add codex dashboard parity --- README.md | 9 + bun.lock | 3 + docs/code-standards.md | 19 +- docs/project-roadmap.md | 3 +- docs/system-architecture/index.md | 17 +- docs/system-architecture/target-adapters.md | 13 + package.json | 1 + src/web-server/routes/codex-routes.ts | 59 ++ src/web-server/routes/index.ts | 4 + .../services/codex-dashboard-service.ts | 359 +++++++++ .../services/compatible-cli-docs-registry.ts | 68 +- .../compatible-cli-toml-file-service.ts | 235 ++++++ .../services/compatible-cli-types.ts | 98 ++- .../codex-dashboard-service.test.ts | 265 +++++++ ui/bun.lock | 6 + ui/package.json | 2 + ui/src/App.tsx | 9 + .../raw-json-settings-editor-panel.tsx | 25 +- ui/src/components/layout/app-sidebar.tsx | 1 + ui/src/components/shared/code-editor.tsx | 39 +- ui/src/hooks/use-codex.ts | 141 ++++ ui/src/pages/codex.tsx | 697 ++++++++++++++++++ ui/src/pages/index.tsx | 2 + .../unit/components/ui/code-editor.test.tsx | 12 + .../dashboard-page-height-contract.test.ts | 17 +- 25 files changed, 2077 insertions(+), 27 deletions(-) create mode 100644 src/web-server/routes/codex-routes.ts create mode 100644 src/web-server/services/codex-dashboard-service.ts create mode 100644 src/web-server/services/compatible-cli-toml-file-service.ts create mode 100644 tests/unit/web-server/codex-dashboard-service.test.ts create mode 100644 ui/src/hooks/use-codex.ts create mode 100644 ui/src/pages/codex.tsx diff --git a/README.md b/README.md index 77ac455b..8bd99406 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ The dashboard provides visual management for all account types: - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **AI Providers**: Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible API keys under `CLIProxy -> AI Providers` - **API Profiles**: Configure GLM, Kimi, OpenRouter, and other Anthropic-compatible APIs as CCS-native profiles +- **Codex CLI**: Dedicated dashboard page for native runtime diagnostics and guarded `config.toml` editing - **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -267,6 +268,14 @@ Not supported in v1: - Non-Codex CLIProxy providers on Codex target - Composite CLIProxy variants on Codex target +Dashboard parity: `ccs config` -> `Compatible` -> `Codex CLI` + +The dedicated Codex dashboard reads and writes the user layer only: `~/.codex/config.toml` +(or `$CODEX_HOME/config.toml`). It shows binary detection, a user-layer summary, support +matrix guidance, and upstream docs, while warning that transient CCS runtime overrides such as +`codex -c key=value` and `CCS_CODEX_API_KEY` can change the effective runtime without persisting +back into that file. + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/bun.lock b/bun.lock index 6ba390ab..ddd5c867 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "open": "^8.4.2", "ora": "^5.4.1", "proper-lockfile": "^4.1.2", + "smol-toml": "^1.6.1", "undici": "^5.29.0", "ws": "^8.16.0", }, @@ -1186,6 +1187,8 @@ "slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/docs/code-standards.md b/docs/code-standards.md index 60e41441..58746d37 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -162,7 +162,7 @@ Allowed when: ## Target Adapter Pattern -The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, etc.) while preserving a unified profile system. +The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, Codex CLI, etc.) while preserving a unified profile system. ### Pattern Overview @@ -170,7 +170,7 @@ The target adapter pattern enables pluggable support for multiple CLI implementa ```typescript interface TargetAdapter { - readonly type: TargetType; // 'claude' | 'droid' + readonly type: TargetType; // 'claude' | 'droid' | 'codex' readonly displayName: string; // Human-readable name detectBinary(): TargetBinaryInfo | null; // Find CLI on system @@ -184,12 +184,12 @@ interface TargetAdapter { ### Key Differences Per Target -| Aspect | Claude | Droid | -|--------|--------|-------| -| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) | -| **Spawn args** | `claude ` | `droid -m custom:ccs- ` | -| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings | -| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check | +| Aspect | Claude | Droid | Codex | +|--------|--------|-------|-------| +| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) | Transient `-c` overrides + `CCS_CODEX_API_KEY` | +| **Spawn args** | `claude ` | `droid -m custom:ccs- ` | `codex ` or `codex -c ... ` | +| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings | None at runtime; dashboard edits user-owned `~/.codex/config.toml` only | +| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check | `detectCodexCli()` plus `--config` capability probe | ### Target Resolution Priority @@ -203,6 +203,8 @@ Resolves which adapter to use via `resolveTargetType()`: 3. argv[0] detection (runtime alias pattern): - ccs-droid → droid - ccsd → droid + - ccs-codex → codex + - ccsx → codex - ccs → default ↓ 4. Fallback: 'claude' (lowest priority) @@ -216,6 +218,7 @@ At startup, adapters self-register into the runtime registry: // In ccs.ts or initialization registerTarget(new ClaudeAdapter()); registerTarget(new DroidAdapter()); +registerTarget(new CodexAdapter()); // Later, when executing const targetType = resolveTargetType(args, profileConfig); diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 7090a9a5..3e273407 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-27 +Last Updated: 2026-03-28 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route. The page detects the local Codex binary, reads and writes the user-owned `~/.codex/config.toml` layer, surfaces support-matrix/runtime-routing guidance, links to official OpenAI Codex docs, and warns that transient CCS runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. - **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. - **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts. - **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected. diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 1fba949e..afb9b624 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2026-03-18 +Last Updated: 2026-03-28 High-level architecture overview for the CCS (Claude Code Switch) system. @@ -8,7 +8,7 @@ High-level architecture overview for the CCS (Claude Code Switch) system. ## System Overview -CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It now supports multiple CLI targets (Claude Code, Factory Droid) for credential delivery. +CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It now supports multiple CLI targets (Claude Code, Factory Droid, Codex CLI) for credential delivery. The system consists of two main components: @@ -25,8 +25,8 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi +===========================================================================+ | | | +------------------+ +-----------------+ +----------------+ | -| | User Terminal | ---> | CCS CLI | ---> | Target CLI | | -| | (ccs command) | | (src/ccs.ts) | | (claude/droid) | | +| | User Terminal | ---> | CCS CLI | ---> | Target CLI | | +| | (ccs command) | | (src/ccs.ts) | | (claude/droid/codex) | | | +------------------+ +-----------------+ +----------------+ | | | | | | v v | @@ -61,7 +61,7 @@ Profile Resolution (CLIProxy, Settings/API, Account-based) Target Resolution (--target flag > config > argv[0] > default) | v -Get Target Adapter (Claude or Droid) +Get Target Adapter (Claude, Droid, or Codex) | +---> detectBinary() (find CLI on system) | @@ -86,12 +86,19 @@ Spawn Target Process - Spawns: `droid -m custom:ccs- ` - Model config includes baseUrl, apiKey, provider +- **Codex Adapter**: Transient runtime overrides plus user-layer dashboard inspection + - Uses `codex -c key=value` only for CCS-routed launches + - Preserves native `~/.codex/config.toml` ownership + - Dashboard page reads/writes only the user config layer with explicit runtime-vs-provider warnings + **Runtime alias pattern (built-in bins / argv[0]-style):** ``` ccs → Target: claude (default) ccs-droid → Target: droid (explicit alias) ccsd → Target: droid (legacy shortcut) +ccs-codex → Target: codex (explicit alias) +ccsx → Target: codex (short alias) ``` For details on the adapter architecture, see [Target Adapters](./target-adapters.md). diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index a390cc23..1fa1eb5d 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -468,6 +468,19 @@ Codex is a real runtime target, but it is intentionally narrower than Claude or | `account` | No | Claude-only account isolation concept | | `copilot` | No | Not a native Codex provider path | +### Codex Dashboard Surface + +CCS also exposes a dedicated dashboard route at `ccs config` -> `Compatible` -> `Codex CLI`. +That page is intentionally narrower than the Droid dashboard: + +- reads and writes only the user config layer: `~/.codex/config.toml` or `$CODEX_HOME/config.toml` +- shows binary detection, user-layer config summaries, support-matrix guidance, and upstream docs +- warns that transient CCS runtime overrides such as `codex -c key=value` and + `CCS_CODEX_API_KEY` can change the effective runtime without persisting into the file editor + +This keeps the dashboard honest about Codex's merged configuration model while still giving users +one place to inspect and manage the user-owned layer safely. + ### Runtime Alias Pattern ```bash diff --git a/package.json b/package.json index 740a24a5..3631fc16 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "open": "^8.4.2", "ora": "^5.4.1", "proper-lockfile": "^4.1.2", + "smol-toml": "^1.6.1", "undici": "^5.29.0", "ws": "^8.16.0" }, diff --git a/src/web-server/routes/codex-routes.ts b/src/web-server/routes/codex-routes.ts new file mode 100644 index 00000000..83cc5f7e --- /dev/null +++ b/src/web-server/routes/codex-routes.ts @@ -0,0 +1,59 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + CodexRawConfigConflictError, + CodexRawConfigValidationError, + getCodexDashboardDiagnostics, + getCodexRawConfig, + saveCodexRawConfig, +} from '../services/codex-dashboard-service'; + +const router = Router(); + +router.get('/diagnostics', async (_req: Request, res: Response): Promise => { + try { + res.json(await getCodexDashboardDiagnostics()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.get('/config/raw', async (_req: Request, res: Response): Promise => { + try { + res.json(await getCodexRawConfig()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/config/raw', async (req: Request, res: Response): Promise => { + try { + const { rawText, expectedMtime } = req.body ?? {}; + + if (typeof rawText !== 'string') { + res.status(400).json({ error: 'rawText must be a string.' }); + return; + } + if ( + expectedMtime !== undefined && + (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) + ) { + res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' }); + return; + } + + res.json(await saveCodexRawConfig({ rawText, expectedMtime })); + } catch (error) { + if (error instanceof CodexRawConfigValidationError) { + res.status(400).json({ error: error.message }); + return; + } + if (error instanceof CodexRawConfigConflictError) { + res.status(409).json({ error: error.message, mtime: error.mtime }); + return; + } + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index e4cd9f74..4a8876e0 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -24,6 +24,7 @@ import aiProviderRoutes from './ai-provider-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; import droidRoutes from './droid-routes'; +import codexRoutes from './codex-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -77,6 +78,9 @@ apiRoutes.use('/cursor', cursorRoutes); // ==================== Droid ==================== apiRoutes.use('/droid', droidRoutes); +// ==================== Codex ==================== +apiRoutes.use('/codex', codexRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts new file mode 100644 index 00000000..244f4bae --- /dev/null +++ b/src/web-server/services/codex-dashboard-service.ts @@ -0,0 +1,359 @@ +import * as os from 'os'; +import * as path from 'path'; +import { expandPath } from '../../utils/helpers'; +import { + codexBinarySupportsConfigOverrides, + getCodexBinaryInfo, +} from '../../targets/codex-detector'; +import type { + CodexDashboardDiagnostics, + CodexFeatureFlagDiagnostics, + CodexMcpServerDiagnostics, + CodexModelProviderDiagnostics, + CodexProjectTrustDiagnostics, + CodexRawConfigResponse, + CodexSupportMatrixEntry, +} from './compatible-cli-types'; +import { + TomlFileConflictError, + TomlFileValidationError, + probeTomlObjectFile, + writeTomlFileAtomic, +} from './compatible-cli-toml-file-service'; +import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; + +interface CodexConfigPaths { + configPath: string; + configDisplayPath: string; + baseDir: string; + baseDirDisplay: string; +} + +interface SaveCodexRawConfigInput { + rawText: string; + expectedMtime?: number; +} + +interface SaveCodexRawConfigResult { + success: true; + mtime: number; +} + +export { + TomlFileConflictError as CodexRawConfigConflictError, + TomlFileValidationError as CodexRawConfigValidationError, +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asObject(value: unknown): Record | null { + return isObject(value) ? value : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function hasOwn(obj: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function parseTransport(server: Record): CodexMcpServerDiagnostics['transport'] { + if (asString(server.command)) return 'stdio'; + if (asString(server.url)) return 'streamable-http'; + return 'unknown'; +} + +export function resolveCodexConfigPaths( + options: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + } = {} +): CodexConfigPaths { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const baseDir = env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex'); + const baseDirDisplay = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex'; + + return { + baseDir, + baseDirDisplay, + configPath: path.join(baseDir, 'config.toml'), + configDisplayPath: `${baseDirDisplay}/config.toml`, + }; +} + +export function summarizeCodexModelProviders(value: unknown): CodexModelProviderDiagnostics[] { + const providers = asObject(value); + if (!providers) return []; + + return Object.entries(providers) + .map(([name, providerValue]) => { + const provider = asObject(providerValue); + if (!provider) return null; + + return { + name, + baseUrl: asString(provider.base_url), + envKey: asString(provider.env_key), + wireApi: asString(provider.wire_api), + requiresOpenaiAuth: provider.requires_openai_auth === true, + supportsWebsockets: provider.supports_websockets === true, + hasQueryParams: + isObject(provider.query_params) && Object.keys(provider.query_params).length > 0, + hasHttpHeaders: + (isObject(provider.http_headers) && Object.keys(provider.http_headers).length > 0) || + (isObject(provider.env_http_headers) && + Object.keys(provider.env_http_headers).length > 0), + usesExperimentalBearerToken: asString(provider.experimental_bearer_token) !== null, + } satisfies CodexModelProviderDiagnostics; + }) + .filter((provider): provider is CodexModelProviderDiagnostics => provider !== null) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function summarizeCodexFeatureFlags(value: unknown): { + all: CodexFeatureFlagDiagnostics[]; + enabled: CodexFeatureFlagDiagnostics[]; + disabled: CodexFeatureFlagDiagnostics[]; +} { + const features = asObject(value); + if (!features) { + return { all: [], enabled: [], disabled: [] }; + } + + const all = Object.entries(features) + .map(([name, rawValue]) => { + const state = rawValue === true ? 'enabled' : rawValue === false ? 'disabled' : 'custom'; + return { name, state } satisfies CodexFeatureFlagDiagnostics; + }) + .sort((left, right) => left.name.localeCompare(right.name)); + + return { + all, + enabled: all.filter((feature) => feature.state === 'enabled'), + disabled: all.filter((feature) => feature.state === 'disabled'), + }; +} + +export function summarizeCodexProjectTrust(value: unknown): CodexProjectTrustDiagnostics[] { + const projects = asObject(value); + if (!projects) return []; + + return Object.entries(projects) + .map(([projectPath, projectValue]) => { + const project = asObject(projectValue); + const trustLevel = project ? asString(project.trust_level) : null; + if (!trustLevel) return null; + return { path: projectPath, trustLevel } satisfies CodexProjectTrustDiagnostics; + }) + .filter((project): project is CodexProjectTrustDiagnostics => project !== null) + .sort((left, right) => left.path.localeCompare(right.path)); +} + +export function summarizeCodexMcpServers(value: unknown): CodexMcpServerDiagnostics[] { + const servers = asObject(value); + if (!servers) return []; + + return Object.entries(servers) + .map(([name, serverValue]) => { + const server = asObject(serverValue); + if (!server) return null; + + const startupTimeoutMs = asNumber(server.startup_timeout_ms); + const startupTimeoutSec = + asNumber(server.startup_timeout_sec) ?? (startupTimeoutMs ? startupTimeoutMs / 1000 : null); + + return { + name, + transport: parseTransport(server), + enabled: server.enabled !== false, + required: server.required === true, + startupTimeoutSec, + toolTimeoutSec: asNumber(server.tool_timeout_sec), + enabledToolsCount: Array.isArray(server.enabled_tools) ? server.enabled_tools.length : 0, + disabledToolsCount: Array.isArray(server.disabled_tools) ? server.disabled_tools.length : 0, + usesInlineBearerToken: hasOwn(server, 'bearer_token'), + } satisfies CodexMcpServerDiagnostics; + }) + .filter((server): server is CodexMcpServerDiagnostics => server !== null) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function getCodexSupportMatrix(): CodexSupportMatrixEntry[] { + return [ + { + id: 'default', + label: 'default', + supported: true, + notes: 'Uses the local Codex CLI with existing native auth and config.', + }, + { + id: 'cliproxy-provider-codex', + label: 'cliproxy provider=codex', + supported: true, + notes: 'Routed through the CLIProxy Codex Responses bridge.', + }, + { + id: 'settings-with-bridge', + label: 'settings with bridge metadata', + supported: true, + notes: 'Supported when the resolved API profile points at a Codex CLIProxy bridge.', + }, + { + id: 'cliproxy-composite', + label: 'cliproxy composite', + supported: false, + notes: 'Not yet proven safe for native Codex routing in CCS v1.', + }, + { + id: 'settings-generic-api', + label: 'settings generic API profile', + supported: false, + notes: 'Generic API profiles stay on Claude or Droid targets.', + }, + { + id: 'account', + label: 'account', + supported: false, + notes: 'Account isolation remains a Claude-oriented concept.', + }, + { + id: 'copilot', + label: 'copilot', + supported: false, + notes: 'GitHub Copilot flow is not a native Codex target path.', + }, + ]; +} + +export async function getCodexDashboardDiagnostics(): Promise { + const paths = resolveCodexConfigPaths(); + const binaryInfo = getCodexBinaryInfo(); + const docsReference = getCompatibleCliDocsReference('codex'); + const fileProbe = await probeTomlObjectFile( + paths.configPath, + 'Codex user config', + paths.configDisplayPath + ); + const config = asObject(fileProbe.config); + const topLevelKeys = config + ? Object.keys(config).sort((left, right) => left.localeCompare(right)) + : []; + const activeProfile = asString(config?.profile); + const profileNames = Object.keys(asObject(config?.profiles) ?? {}).sort((left, right) => + left.localeCompare(right) + ); + const modelProviders = summarizeCodexModelProviders(config?.model_providers); + const features = summarizeCodexFeatureFlags(config?.features); + const projectTrust = summarizeCodexProjectTrust(config?.projects); + const mcpServers = summarizeCodexMcpServers(config?.mcp_servers); + const supportMatrix = getCodexSupportMatrix(); + + const warnings: string[] = []; + if (!binaryInfo) { + warnings.push('Codex binary is not detected in PATH or CCS_CODEX_PATH.'); + } else if (!codexBinarySupportsConfigOverrides(binaryInfo)) { + warnings.push( + 'This Codex build does not expose --config overrides required for CCS-backed Codex routing.' + ); + } + + if (fileProbe.diagnostics.parseError) { + warnings.push(`${paths.configDisplayPath} contains invalid TOML.`); + } + if (activeProfile && !profileNames.includes(activeProfile)) { + warnings.push(`Active profile "${activeProfile}" is selected but missing from [profiles].`); + } + if (modelProviders.some((provider) => provider.usesExperimentalBearerToken)) { + warnings.push( + 'One or more model_providers entries use experimental_bearer_token; prefer env_key-backed auth.' + ); + } + if (mcpServers.some((server) => server.usesInlineBearerToken)) { + warnings.push( + 'One or more mcp_servers entries include inline bearer_token; prefer bearer_token_env_var.' + ); + } + + return { + binary: { + installed: !!binaryInfo, + path: binaryInfo?.path ?? null, + installDir: binaryInfo?.path ? path.dirname(binaryInfo.path) : null, + source: process.env.CCS_CODEX_PATH ? 'CCS_CODEX_PATH' : binaryInfo ? 'PATH' : 'missing', + version: binaryInfo?.version ?? null, + overridePath: process.env.CCS_CODEX_PATH || null, + supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo), + }, + file: fileProbe.diagnostics, + config: { + model: asString(config?.model), + modelProvider: asString(config?.model_provider), + activeProfile, + approvalPolicy: asString(config?.approval_policy), + sandboxMode: asString(config?.sandbox_mode), + webSearch: asString(config?.web_search), + topLevelKeys, + profileCount: profileNames.length, + profileNames, + modelProviderCount: modelProviders.length, + modelProviders, + featureCount: features.all.length, + enabledFeatures: features.enabled, + disabledFeatures: features.disabled, + trustedProjectCount: projectTrust.filter((entry) => entry.trustLevel === 'trusted').length, + untrustedProjectCount: projectTrust.filter((entry) => entry.trustLevel !== 'trusted').length, + projectTrust, + mcpServerCount: mcpServers.length, + mcpServers, + }, + supportMatrix, + warnings, + docsReference, + }; +} + +export async function getCodexRawConfig(): Promise { + const paths = resolveCodexConfigPaths(); + const fileProbe = await probeTomlObjectFile( + paths.configPath, + 'Codex user config', + paths.configDisplayPath + ); + + return { + path: paths.configDisplayPath, + resolvedPath: paths.configPath, + exists: fileProbe.diagnostics.exists, + mtime: fileProbe.diagnostics.mtimeMs ?? Date.now(), + rawText: fileProbe.rawText, + config: fileProbe.config, + parseError: fileProbe.diagnostics.parseError, + }; +} + +export async function saveCodexRawConfig( + input: SaveCodexRawConfigInput +): Promise { + const paths = resolveCodexConfigPaths(); + if (typeof input.rawText !== 'string') { + throw new TomlFileValidationError('rawText must be a string.'); + } + + const saved = await writeTomlFileAtomic({ + filePath: paths.configPath, + rawText: input.rawText, + expectedMtime: input.expectedMtime, + fileLabel: 'config.toml', + }); + + return { success: true, mtime: saved.mtime }; +} diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts index ac3fc862..5cba7137 100644 --- a/src/web-server/services/compatible-cli-docs-registry.ts +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -3,7 +3,7 @@ export interface CompatibleCliDocLink { label: string; url: string; category: 'overview' | 'configuration' | 'byok' | 'reference'; - source: 'factory' | 'provider'; + source: 'factory' | 'provider' | 'openai' | 'github'; description: string; } @@ -96,6 +96,72 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record] overlay on top of base config', + 'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY', + 'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces', + ], + links: [ + { + id: 'codex-config-basic', + label: 'Codex Config Basics', + url: 'https://developers.openai.com/codex/config-basic', + category: 'overview', + source: 'openai', + description: + 'Official user-layer setup, config location, and basic configuration guidance.', + }, + { + id: 'codex-config-advanced', + label: 'Codex Config Advanced', + url: 'https://developers.openai.com/codex/config-advanced', + category: 'configuration', + source: 'openai', + description: 'Advanced layering, project trust, profiles, and stricter config behaviors.', + }, + { + id: 'codex-config-reference', + label: 'Codex Config Reference', + url: 'https://developers.openai.com/codex/config-reference', + category: 'reference', + source: 'openai', + description: + 'Canonical upstream config schema surface for model providers, features, MCP, and more.', + }, + { + id: 'codex-releases', + label: 'Codex GitHub Releases', + url: 'https://github.com/openai/codex/releases', + category: 'reference', + source: 'github', + description: + 'Track CLI release notes and upstream behavior changes across stable and prerelease builds.', + }, + ], + providerDocs: [ + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, + ], + }, + }, }; export function getCompatibleCliDocsReference(cliId: string): CompatibleCliDocsReference { diff --git a/src/web-server/services/compatible-cli-toml-file-service.ts b/src/web-server/services/compatible-cli-toml-file-service.ts new file mode 100644 index 00000000..b7780711 --- /dev/null +++ b/src/web-server/services/compatible-cli-toml-file-service.ts @@ -0,0 +1,235 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { parse } from 'smol-toml'; + +export interface TomlFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface TomlFileProbe { + diagnostics: TomlFileDiagnostics; + config: Record | null; + rawText: string; +} + +interface WriteTomlFileInput { + filePath: string; + rawText: string; + expectedMtime?: number; + fileLabel?: string; + dirMode?: number; + fileMode?: number; +} + +interface WriteTomlFileResult { + mtime: number; +} + +export class TomlFileValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'TomlFileValidationError'; + } +} + +export class TomlFileConflictError extends Error { + readonly code = 'CONFLICT'; + readonly mtime: number; + + constructor(message: string, mtime: number) { + super(message); + this.name = 'TomlFileConflictError'; + this.mtime = mtime; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function statPath(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } +} + +export function parseTomlObjectText( + rawText: string, + fieldName = 'rawText' +): Record { + if (typeof rawText !== 'string') { + throw new TomlFileValidationError(`${fieldName} must be a string.`); + } + + const trimmed = rawText.trim(); + if (!trimmed) return {}; + + let parsed: unknown; + try { + parsed = parse(rawText); + } catch (error) { + throw new TomlFileValidationError(`Invalid TOML in ${fieldName}: ${(error as Error).message}`); + } + + if (!isObject(parsed)) { + throw new TomlFileValidationError(`${fieldName} TOML root must be a table.`); + } + + return parsed; +} + +export async function probeTomlObjectFile( + filePath: string, + label: string, + displayPath: string +): Promise { + const stat = await statPath(filePath); + if (!stat) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + config: null, + rawText: '', + }; + } + + const diagnostics: TomlFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, config: null, rawText: '' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, config: null, rawText: '' }; + } + + try { + const rawText = await fs.readFile(filePath, 'utf8'); + try { + const config = parseTomlObjectText(rawText, displayPath); + return { diagnostics, config, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, config: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, config: null, rawText: '' }; + } +} + +export async function writeTomlFileAtomic(input: WriteTomlFileInput): Promise { + const fileLabel = input.fileLabel || path.basename(input.filePath); + parseTomlObjectText(input.rawText, fileLabel); + + const targetPath = input.filePath; + const targetDir = path.dirname(targetPath); + const tempPath = targetPath + '.tmp'; + const dirMode = input.dirMode ?? 0o700; + const fileMode = input.fileMode ?? 0o600; + + await fs.mkdir(targetDir, { recursive: true, mode: dirMode }); + + const targetStat = await statPath(targetPath); + if (targetStat) { + if (targetStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); + } + if (!targetStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`); + } + + if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) { + throw new TomlFileConflictError( + 'File metadata not loaded. Refresh and retry.', + targetStat.mtimeMs + ); + } + if (Math.abs(targetStat.mtimeMs - input.expectedMtime) > 1000) { + throw new TomlFileConflictError('File modified externally.', targetStat.mtimeMs); + } + } + + let wroteTemp = false; + try { + const existingTempStat = await statPath(tempPath); + if (existingTempStat) { + if (existingTempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!existingTempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + } + + await fs.writeFile(tempPath, input.rawText, { mode: fileMode }); + wroteTemp = true; + + const tempStat = await fs.lstat(tempPath); + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + + await fs.rename(tempPath, targetPath); + wroteTemp = false; + + try { + await fs.chmod(targetPath, fileMode); + } catch { + // Best-effort permission hardening. + } + + const stat = await fs.stat(targetPath); + return { mtime: stat.mtimeMs }; + } finally { + if (wroteTemp) { + try { + await fs.unlink(tempPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + } +} diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts index 6d93c1ce..e3c749bb 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -49,7 +49,7 @@ export interface CompatibleCliDocLink { label: string; url: string; category: 'overview' | 'configuration' | 'byok' | 'reference'; - source: 'factory' | 'provider'; + source: 'factory' | 'provider' | 'openai' | 'github'; description: string; } @@ -88,3 +88,99 @@ export interface DroidRawSettingsResponse { settings: Record | null; parseError: string | null; } + +export type CodexBinarySource = 'CCS_CODEX_PATH' | 'PATH' | 'missing'; + +export interface CodexBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: CodexBinarySource; + version: string | null; + overridePath: string | null; + supportsConfigOverrides: boolean; +} + +export type CodexConfigFileDiagnostics = DroidConfigFileDiagnostics; + +export interface CodexModelProviderDiagnostics { + name: string; + baseUrl: string | null; + envKey: string | null; + wireApi: string | null; + requiresOpenaiAuth: boolean; + supportsWebsockets: boolean; + hasQueryParams: boolean; + hasHttpHeaders: boolean; + usesExperimentalBearerToken: boolean; +} + +export interface CodexFeatureFlagDiagnostics { + name: string; + state: 'enabled' | 'disabled' | 'custom'; +} + +export interface CodexProjectTrustDiagnostics { + path: string; + trustLevel: string; +} + +export interface CodexMcpServerDiagnostics { + name: string; + transport: 'stdio' | 'streamable-http' | 'unknown'; + enabled: boolean; + required: boolean; + startupTimeoutSec: number | null; + toolTimeoutSec: number | null; + enabledToolsCount: number; + disabledToolsCount: number; + usesInlineBearerToken: boolean; +} + +export interface CodexSupportMatrixEntry { + id: string; + label: string; + supported: boolean; + notes: string; +} + +export interface CodexUserConfigDiagnostics { + model: string | null; + modelProvider: string | null; + activeProfile: string | null; + approvalPolicy: string | null; + sandboxMode: string | null; + webSearch: string | null; + topLevelKeys: string[]; + profileCount: number; + profileNames: string[]; + modelProviderCount: number; + modelProviders: CodexModelProviderDiagnostics[]; + featureCount: number; + enabledFeatures: CodexFeatureFlagDiagnostics[]; + disabledFeatures: CodexFeatureFlagDiagnostics[]; + trustedProjectCount: number; + untrustedProjectCount: number; + projectTrust: CodexProjectTrustDiagnostics[]; + mcpServerCount: number; + mcpServers: CodexMcpServerDiagnostics[]; +} + +export interface CodexDashboardDiagnostics { + binary: CodexBinaryDiagnostics; + file: CodexConfigFileDiagnostics; + config: CodexUserConfigDiagnostics; + supportMatrix: CodexSupportMatrixEntry[]; + warnings: string[]; + docsReference: CompatibleCliDocsReference; +} + +export interface CodexRawConfigResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + config: Record | null; + parseError: string | null; +} diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts new file mode 100644 index 00000000..bfbdf179 --- /dev/null +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -0,0 +1,265 @@ +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 { + CodexRawConfigConflictError, + CodexRawConfigValidationError, + getCodexDashboardDiagnostics, + getCodexRawConfig, + resolveCodexConfigPaths, + saveCodexRawConfig, + summarizeCodexFeatureFlags, + summarizeCodexMcpServers, + summarizeCodexModelProviders, + summarizeCodexProjectTrust, +} from '../../../src/web-server/services/codex-dashboard-service'; + +const testRoot = path.join(os.tmpdir(), `ccs-codex-dashboard-test-${Date.now()}`); +const codexHome = path.join(testRoot, '.codex-home'); +const codexStubPath = path.join(testRoot, 'codex'); + +function writeCodexStub(options?: { helpText?: string; version?: string }) { + const helpText = options?.helpText ?? ' -c, --config \n -p, --profile \n'; + const version = options?.version ?? 'codex-cli 0.118.0-alpha.3'; + + fs.writeFileSync( + codexStubPath, + `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' "${version}" + exit 0 +fi +if [ "$1" = "--help" ]; then + printf '%s' "${helpText}" + exit 0 +fi +exit 0 +` + ); + fs.chmodSync(codexStubPath, 0o755); +} + +beforeEach(() => { + fs.mkdirSync(testRoot, { recursive: true }); + fs.mkdirSync(codexHome, { recursive: true }); + writeCodexStub(); + process.env.CODEX_HOME = codexHome; + process.env.CCS_CODEX_PATH = codexStubPath; +}); + +afterEach(() => { + delete process.env.CODEX_HOME; + delete process.env.CCS_CODEX_PATH; + if (fs.existsSync(testRoot)) { + fs.rmSync(testRoot, { recursive: true, force: true }); + } +}); + +describe('codex-dashboard-service', () => { + it('resolves codex config paths with CODEX_HOME override', () => { + const resolved = resolveCodexConfigPaths({ + env: { + CODEX_HOME: '/tmp/custom-codex-home', + } as NodeJS.ProcessEnv, + homeDir: '/Users/tester', + }); + + expect(resolved.baseDir).toBe('/tmp/custom-codex-home'); + expect(resolved.baseDirDisplay).toBe('$CODEX_HOME'); + expect(resolved.configPath).toBe('/tmp/custom-codex-home/config.toml'); + expect(resolved.configDisplayPath).toBe('$CODEX_HOME/config.toml'); + }); + + it('summarizes model providers with auth and header metadata', () => { + const summary = summarizeCodexModelProviders({ + cliproxy: { + base_url: 'http://127.0.0.1:8317/api/provider/codex', + env_key: 'CLIPROXY_API_KEY', + wire_api: 'responses', + http_headers: { 'x-test': '1' }, + }, + local: { + base_url: 'http://localhost:11434/v1', + experimental_bearer_token: 'secret', + supports_websockets: true, + }, + }); + + expect(summary.length).toBe(2); + expect(summary[0].name).toBe('cliproxy'); + expect(summary[0].envKey).toBe('CLIPROXY_API_KEY'); + expect(summary[0].hasHttpHeaders).toBe(true); + expect(summary[1].usesExperimentalBearerToken).toBe(true); + }); + + it('summarizes feature flags, project trust, and mcp servers', () => { + const features = summarizeCodexFeatureFlags({ + multi_agent: true, + shell_snapshot: false, + custom_mode: 'beta', + }); + const projects = summarizeCodexProjectTrust({ + '/tmp/a': { trust_level: 'trusted' }, + '/tmp/b': { trust_level: 'ask' }, + }); + const servers = summarizeCodexMcpServers({ + stdio: { + command: 'npx', + enabled_tools: ['browser_snapshot'], + }, + remote: { + url: 'https://example.test/mcp', + bearer_token: 'not-allowed-inline', + required: true, + }, + }); + + expect(features.enabled.map((feature) => feature.name)).toEqual(['multi_agent']); + expect(features.disabled.map((feature) => feature.name)).toEqual(['shell_snapshot']); + expect(features.all.find((feature) => feature.name === 'custom_mode')?.state).toBe('custom'); + expect(projects.length).toBe(2); + expect(projects[0].trustLevel).toBe('trusted'); + expect(servers[0].transport).toBe('streamable-http'); + expect(servers[0].usesInlineBearerToken).toBe(true); + }); + + it('returns raw config payload for missing config.toml', async () => { + const raw = await getCodexRawConfig(); + + expect(raw.exists).toBe(false); + expect(raw.path).toBe('$CODEX_HOME/config.toml'); + expect(raw.rawText).toBe(''); + expect(raw.config).toBeNull(); + }); + + it('returns parseError when config.toml is invalid TOML', async () => { + fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n'); + + const raw = await getCodexRawConfig(); + + expect(raw.exists).toBe(true); + expect(raw.parseError).toBeString(); + expect(raw.config).toBeNull(); + }); + + it('includes docs links, support matrix, and config summaries in diagnostics', async () => { + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + `model = "gpt-5.4" +profile = "work" +model_provider = "cliproxy" +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +multi_agent = true +shell_snapshot = false +runtime_metrics = true + +[model_providers.cliproxy] +name = "CLIProxyAPI" +base_url = "http://127.0.0.1:8317/api/provider/codex" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" + +[projects."/tmp/project-a"] +trust_level = "trusted" + +[projects."/tmp/project-b"] +trust_level = "ask" + +[mcp_servers.playwright] +command = "npx" +args = ["@playwright/mcp@latest"] +enabled_tools = ["browser_snapshot"] +tool_timeout_sec = 30 + +[profiles.work] +model = "gpt-5.4" +` + ); + + const diagnostics = await getCodexDashboardDiagnostics(); + + expect(diagnostics.binary.installed).toBe(true); + expect(diagnostics.binary.supportsConfigOverrides).toBe(true); + expect(diagnostics.config.model).toBe('gpt-5.4'); + expect(diagnostics.config.activeProfile).toBe('work'); + expect(diagnostics.config.modelProvider).toBe('cliproxy'); + expect(diagnostics.config.profileCount).toBe(1); + expect(diagnostics.config.modelProviderCount).toBe(1); + expect(diagnostics.config.featureCount).toBe(3); + expect(diagnostics.config.enabledFeatures.map((feature) => feature.name)).toEqual([ + 'multi_agent', + 'runtime_metrics', + ]); + expect(diagnostics.config.trustedProjectCount).toBe(1); + expect(diagnostics.config.untrustedProjectCount).toBe(1); + expect(diagnostics.config.mcpServerCount).toBe(1); + expect(diagnostics.docsReference.links.length).toBeGreaterThan(0); + expect(diagnostics.supportMatrix.some((entry) => entry.id === 'default')).toBe(true); + }); + + it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => { + writeCodexStub({ helpText: ' -p, --profile \n' }); + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + `profile = "missing-profile" + +[model_providers.local] +experimental_bearer_token = "secret" + +[mcp_servers.remote] +url = "https://example.test/mcp" +bearer_token = "secret" +` + ); + + const diagnostics = await getCodexDashboardDiagnostics(); + + expect( + diagnostics.warnings.some((warning) => warning.includes('does not expose --config overrides')) + ).toBe(true); + expect( + diagnostics.warnings.some((warning) => warning.includes('missing from [profiles]')) + ).toBe(true); + expect( + diagnostics.warnings.some((warning) => warning.includes('experimental_bearer_token')) + ).toBe(true); + expect(diagnostics.warnings.some((warning) => warning.includes('inline bearer_token'))).toBe( + true + ); + }); + + it('saves valid raw config content', async () => { + const result = await saveCodexRawConfig({ + rawText: 'model = "gpt-5.4"\n[features]\nmulti_agent = true\n', + }); + + const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + + expect(result.success).toBe(true); + expect(result.mtime).toBeGreaterThan(0); + expect(written).toContain('model = "gpt-5.4"'); + }); + + it('rejects invalid TOML while saving raw config', async () => { + await expect(saveCodexRawConfig({ rawText: 'model = "gpt-5.4"\n[features\n' })).rejects.toThrow( + CodexRawConfigValidationError + ); + }); + + it('rejects stale writes with conflict error', async () => { + const configPath = path.join(codexHome, 'config.toml'); + fs.writeFileSync(configPath, 'model = "gpt-5.4"\n'); + + await expect( + saveCodexRawConfig({ + rawText: 'model = "gpt-5.3-codex"\n', + expectedMtime: 1, + }) + ).rejects.toThrow(CodexRawConfigConflictError); + }); +}); diff --git a/ui/bun.lock b/ui/bun.lock index 6a181f1a..d79f76ca 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -31,6 +31,7 @@ "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", + "prismjs": "^1.30.0", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", @@ -41,6 +42,7 @@ "react-simple-code-editor": "^0.14.1", "react-virtuoso": "^4.17.0", "recharts": "^2.12.0", + "smol-toml": "^1.6.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "yaml": "^2.8.2", @@ -925,6 +927,8 @@ "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1001,6 +1005,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/ui/package.json b/ui/package.json index 0dc9b46e..a5a7ad53 100644 --- a/ui/package.json +++ b/ui/package.json @@ -45,6 +45,7 @@ "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", + "prismjs": "^1.30.0", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", @@ -55,6 +56,7 @@ "react-simple-code-editor": "^0.14.1", "react-virtuoso": "^4.17.0", "recharts": "^2.12.0", + "smol-toml": "^1.6.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "yaml": "^2.8.2", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b065cbc1..ba45cce4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -34,6 +34,7 @@ const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m const ClaudeExtensionPage = lazy(() => import('@/pages/claude-extension').then((m) => ({ default: m.ClaudeExtensionPage })) ); +const CodexPage = lazy(() => import('@/pages/codex').then((m) => ({ default: m.CodexPage }))); const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) @@ -141,6 +142,14 @@ export default function App() { } /> + }> + + + } + /> void; onSave: () => Promise | void; onRefresh: () => Promise | void; + language?: 'json' | 'yaml' | 'toml'; + loadingLabel?: string; + parseWarningLabel?: string; + ownershipNotice?: ReactNode; } -export function RawJsonSettingsEditorPanel({ +export function RawConfigEditorPanel({ title, pathLabel, loading, @@ -33,7 +37,11 @@ export function RawJsonSettingsEditorPanel({ onChange, onSave, onRefresh, -}: RawJsonSettingsEditorPanelProps) { + language = 'json', + loadingLabel = 'Loading settings.json...', + parseWarningLabel = 'Parse warning', + ownershipNotice, +}: RawConfigEditorPanelProps) { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -82,13 +90,14 @@ export function RawJsonSettingsEditorPanel({ {loading ? (
- Loading settings.json... + {loadingLabel}
) : (
+ {ownershipNotice &&
{ownershipNotice}
} {parseWarning && (
- Parse warning: {parseWarning} + {parseWarningLabel}: {parseWarning}
)}
@@ -96,7 +105,7 @@ export function RawJsonSettingsEditorPanel({ @@ -108,3 +117,5 @@ export function RawJsonSettingsEditorPanel({
); } + +export const RawJsonSettingsEditorPanel = RawConfigEditorPanel; diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index 3c7e56cd..4697aac6 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -119,6 +119,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { title: t('nav.compatibleClis'), items: [ { path: '/claude-extension', icon: Puzzle, label: t('nav.claudeExtension') }, + { path: '/codex', iconSrc: '/assets/providers/codex-color.svg', label: 'Codex CLI' }, { path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }, ], }, diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index 5dbc3fd4..c98857a4 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -1,12 +1,17 @@ /** * Code Editor Component - * Lightweight JSON editor with syntax highlighting, line numbers, and validation + * Lightweight JSON/TOML editor with syntax highlighting, line numbers, and validation * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) */ import { useState, useCallback, useMemo } from 'react'; import Editor from 'react-simple-code-editor'; import { Highlight, themes } from 'prism-react-renderer'; +import Prism from 'prismjs'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-yaml'; +import 'prismjs/components/prism-toml'; +import { parse as parseToml } from 'smol-toml'; import { useTheme } from '@/hooks/use-theme'; import { cn } from '@/lib/utils'; import { isSensitiveKey } from '@/lib/sensitive-keys'; @@ -16,7 +21,7 @@ import { Button } from '@/components/ui/button'; interface CodeEditorProps { value: string; onChange: (value: string) => void; - language?: 'json' | 'yaml'; + language?: 'json' | 'yaml' | 'toml'; readonly?: boolean; className?: string; minHeight?: string; @@ -64,6 +69,26 @@ function validateJson(code: string): ValidationResult { } } +function validateToml(code: string): ValidationResult { + if (!code.trim()) { + return { valid: true }; + } + + try { + parseToml(code); + return { valid: true }; + } catch (error) { + const message = (error as Error).message; + const lineMatch = message.match(/line\s+(\d+)/i); + + return { + valid: false, + error: message, + line: lineMatch ? Number.parseInt(lineMatch[1], 10) : undefined, + }; + } +} + export function CodeEditor({ value, onChange, @@ -83,6 +108,9 @@ export function CodeEditor({ if (language === 'json') { return validateJson(value); } + if (language === 'toml') { + return validateToml(value); + } return { valid: true }; }, [value, language]); @@ -90,7 +118,12 @@ export function CodeEditor({ // Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor const highlightCode = useCallback( (code: string) => ( - + {({ tokens, getLineProps, getTokenProps }) => { let nextValueIsSensitive = false; diff --git a/ui/src/hooks/use-codex.ts b/ui/src/hooks/use-codex.ts new file mode 100644 index 00000000..f486d571 --- /dev/null +++ b/ui/src/hooks/use-codex.ts @@ -0,0 +1,141 @@ +import { useMemo } from 'react'; +import { parse as parseToml } from 'smol-toml'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ApiConflictError, withApiBase } from '@/lib/api-client'; +import type { + CodexDashboardDiagnostics, + CodexRawConfigResponse, +} from '../../../src/web-server/services/compatible-cli-types'; + +type CodexRawConfig = CodexRawConfigResponse; + +interface SaveCodexRawConfigInput { + rawText: string; + expectedMtime?: number; +} + +interface SaveCodexRawConfigResponse { + success: true; + mtime: number; +} + +function parseCodexRawConfigText(rawText: string): { + config: Record | null; + parseError: string | null; +} { + try { + const parsed = rawText.trim() ? parseToml(rawText) : {}; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { + config: null, + parseError: 'TOML root must be a table.', + }; + } + return { + config: parsed as Record, + parseError: null, + }; + } catch (error) { + return { + config: null, + parseError: (error as Error).message, + }; + } +} + +async function fetchCodexDiagnostics(): Promise { + const res = await fetch(withApiBase('/codex/diagnostics')); + if (!res.ok) throw new Error('Failed to fetch Codex diagnostics'); + return res.json(); +} + +async function fetchCodexRawConfig(): Promise { + const res = await fetch(withApiBase('/codex/config/raw')); + if (!res.ok) throw new Error('Failed to fetch Codex raw config'); + return res.json(); +} + +async function saveCodexRawConfig( + data: SaveCodexRawConfigInput +): Promise { + const res = await fetch(withApiBase('/codex/config/raw'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.status === 409) throw new ApiConflictError('Codex raw config changed externally'); + + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error || 'Failed to save Codex raw config'); + } + return res.json(); +} + +export function useCodex() { + const queryClient = useQueryClient(); + + const diagnosticsQuery = useQuery({ + queryKey: ['codex-diagnostics'], + queryFn: fetchCodexDiagnostics, + refetchInterval: 10000, + }); + + const rawConfigQuery = useQuery({ + queryKey: ['codex-raw-config'], + queryFn: fetchCodexRawConfig, + }); + + const saveRawConfigMutation = useMutation({ + mutationFn: saveCodexRawConfig, + onSuccess: (result, variables) => { + queryClient.setQueryData(['codex-raw-config'], (current) => { + const path = current?.path ?? '$CODEX_HOME/config.toml'; + const resolvedPath = current?.resolvedPath ?? path; + const parsed = parseCodexRawConfigText(variables.rawText); + + return { + path, + resolvedPath, + exists: true, + mtime: result.mtime, + rawText: variables.rawText, + config: parsed.config, + parseError: parsed.parseError, + }; + }); + queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] }); + }, + }); + + return useMemo( + () => ({ + diagnostics: diagnosticsQuery.data, + diagnosticsLoading: diagnosticsQuery.isLoading, + diagnosticsError: diagnosticsQuery.error, + refetchDiagnostics: diagnosticsQuery.refetch, + + rawConfig: rawConfigQuery.data, + rawConfigLoading: rawConfigQuery.isLoading, + rawConfigError: rawConfigQuery.error, + refetchRawConfig: rawConfigQuery.refetch, + + saveRawConfig: saveRawConfigMutation.mutate, + saveRawConfigAsync: saveRawConfigMutation.mutateAsync, + isSavingRawConfig: saveRawConfigMutation.isPending, + }), + [ + diagnosticsQuery.data, + diagnosticsQuery.isLoading, + diagnosticsQuery.error, + diagnosticsQuery.refetch, + rawConfigQuery.data, + rawConfigQuery.isLoading, + rawConfigQuery.error, + rawConfigQuery.refetch, + saveRawConfigMutation.mutate, + saveRawConfigMutation.mutateAsync, + saveRawConfigMutation.isPending, + ] + ); +} diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx new file mode 100644 index 00000000..ef5b8fe6 --- /dev/null +++ b/ui/src/pages/codex.tsx @@ -0,0 +1,697 @@ +import { type ReactNode, useState } from 'react'; +import { parse as parseToml } from 'smol-toml'; +import { toast } from 'sonner'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + FileWarning, + Folder, + GripVertical, + Info, + Loader2, + Route, + ShieldCheck, + TerminalSquare, + XCircle, +} from 'lucide-react'; +import { useCodex } from '@/hooks/use-codex'; +import { isApiConflictError } from '@/lib/api-client'; +import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; +import { UsageCommand } from '@/components/cliproxy/provider-editor/usage-command'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; + +const DEFAULT_CODEX_DOC_LINKS = [ + { + id: 'codex-config-basic', + label: 'Codex Config Basics', + url: 'https://developers.openai.com/codex/config-basic', + description: 'Official user-layer setup, config location, and baseline configuration behavior.', + }, + { + id: 'codex-config-advanced', + label: 'Codex Config Advanced', + url: 'https://developers.openai.com/codex/config-advanced', + description: 'Layering, trust, profiles, and advanced config behavior.', + }, + { + id: 'codex-config-reference', + label: 'Codex Config Reference', + url: 'https://developers.openai.com/codex/config-reference', + description: 'Canonical upstream config surface for providers, MCP, features, and trust.', + }, + { + id: 'codex-releases', + label: 'Codex GitHub Releases', + url: 'https://github.com/openai/codex/releases', + description: 'Track upstream release notes and fast-moving CLI changes.', + }, +]; + +const DEFAULT_PROVIDER_DOCS = [ + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, +]; + +function renderTextWithLinks(text: string): ReactNode[] { + const urlPattern = /https?:\/\/[^\s)]+/g; + const nodes: ReactNode[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(text)) !== null) { + const [url] = match; + const index = match.index; + + if (index > cursor) { + nodes.push(text.slice(cursor, index)); + } + + nodes.push( + + {url} + + ); + cursor = index + url.length; + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return 'N/A'; + return new Date(value).toLocaleString(); +} + +function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(2)} MB`; +} + +function parseTomlObjectText( + text: string +): { valid: true; value: Record } | { valid: false; error: string } { + try { + const parsed = text.trim() ? parseToml(text) : {}; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { valid: false, error: 'TOML root must be a table.' }; + } + return { valid: true, value: parsed as Record }; + } catch (error) { + return { valid: false, error: (error as Error).message }; + } +} + +function DetailRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function CodexPage() { + const { + diagnostics, + diagnosticsLoading, + diagnosticsError, + refetchDiagnostics, + rawConfig, + rawConfigLoading, + refetchRawConfig, + saveRawConfigAsync, + isSavingRawConfig, + } = useCodex(); + + const [rawDraftText, setRawDraftText] = useState(null); + const rawBaseText = rawConfig?.rawText ?? ''; + const rawEditorText = rawDraftText ?? rawBaseText; + const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; + const rawEditorParsed = parseTomlObjectText(rawEditorText); + const rawEditorValidation = rawEditorParsed.valid + ? { valid: true as const } + : { valid: false as const, error: rawEditorParsed.error }; + + const setRawEditorDraftText = (nextText: string) => { + if (nextText === rawBaseText) { + setRawDraftText(null); + return; + } + setRawDraftText(nextText); + }; + + const refreshAll = async () => { + await Promise.all([refetchDiagnostics(), refetchRawConfig()]); + }; + + const handleSaveRawConfig = async () => { + if (!rawEditorValidation.valid) { + toast.error('Fix TOML before saving.'); + return; + } + + try { + await saveRawConfigAsync({ + rawText: rawEditorText, + expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined, + }); + setRawDraftText(null); + toast.success('Saved Codex config.toml.'); + await refetchDiagnostics(); + } catch (error) { + if (isApiConflictError(error)) { + toast.error('config.toml changed externally. Refresh and retry.'); + } else { + toast.error((error as Error).message || 'Failed to save Codex config.toml.'); + } + } + }; + + const renderOverview = () => { + if (diagnosticsLoading) { + return ( +
+ + Loading Codex diagnostics... +
+ ); + } + + if (diagnosticsError || !diagnostics) { + return ( +
+ Failed to load Codex diagnostics. +
+ ); + } + + const docsReference = diagnostics.docsReference ?? { + notes: [], + links: [], + providerDocs: [], + providerValues: [], + settingsHierarchy: [], + }; + const docsLinks = + docsReference.links.length > 0 ? docsReference.links : DEFAULT_CODEX_DOC_LINKS; + const providerDocs = + docsReference.providerDocs.length > 0 ? docsReference.providerDocs : DEFAULT_PROVIDER_DOCS; + const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden'; + + return ( + +
+ + Overview + Runtime & Routing + Docs + +
+ +
+ + +
+ + + + + How Codex works in CCS + + + +

+ Codex is a first-class runtime target in CCS, but it stays runtime-only in v1. +

+

+ Saved default targets for API profiles and variants still remain on Claude or + Droid. +

+

+ CCS-backed Codex launches can apply transient -c overrides and + inject CCS_CODEX_API_KEY, so effective runtime values may not + match this file exactly. +

+
+
+ + + + + + Runtime install + + + +
+ Status + + {diagnostics.binary.installed ? 'Detected' : 'Not found'} + +
+ + + + + +
+ + --config override support + + + {diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'} + +
+
+
+ + + + + + Config file + + + +
+
+ User config + {diagnostics.file.exists ? ( + + ) : ( + + )} +
+ + + + + {diagnostics.file.parseError && ( +

+ TOML warning: {diagnostics.file.parseError} +

+ )} + {diagnostics.file.readError && ( +

+ Read warning: {diagnostics.file.readError} +

+ )} +
+
+
+ + + + + + Current user-layer summary + + + + + + + + + + +
+ + providers: {diagnostics.config.modelProviderCount} + + + profiles: {diagnostics.config.profileCount} + + + enabled features: {diagnostics.config.enabledFeatures.length} + + + MCP servers: {diagnostics.config.mcpServerCount} + +
+ {diagnostics.config.topLevelKeys.length > 0 && ( +
+

+ User-layer keys present +

+
+ {diagnostics.config.topLevelKeys.map((key) => ( + + {key} + + ))} +
+
+ )} +
+
+ + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+
+ + + +
+ + + + + Runtime vs provider + + + +
+

Native Codex runtime

+

+ Use ccs-codex, ccsx, or{' '} + --target codex. CCS launches the local Codex CLI and depends on + native Codex capabilities such as --config overrides. +

+
+
+

Codex provider / bridge

+

+ CCS can route provider credentials transiently through CLIProxy. That is not + the same as editing local config.toml, and some routed values + may never persist here. +

+
+
+
+ + + + Supported flows + + + + + + Flow + Status + Notes + + + + {diagnostics.supportMatrix.map((entry) => ( + + {entry.label} + + + {entry.supported ? 'Yes' : 'No'} + + + + {entry.notes} + + + ))} + +
+
+
+ + + + Quick usage + + + + + + + + + + + + + + What this editor affects + + + +

+ Edits here affect native Codex sessions first because this is the user-layer{' '} + config.toml. +

+

+ CCS-routed Codex launches may override provider-related keys transiently and + inject CCS_CODEX_API_KEY. +

+

+ That means the file is not a complete source of truth for every routed Codex + launch you start from CCS. +

+
+
+
+
+
+ + + +
+ + + + + Upstream notes + + + + {docsReference.notes.map((note, index) => ( +

+ - {renderTextWithLinks(note)} +

+ ))} + +
+

+ Codex docs +

+ +
+ +
+

+ Provider / bridge reference +

+ +
+ {docsReference.providerValues.length > 0 && ( + <> + +

+ Provider values: {docsReference.providerValues.join(', ')} +

+ + )} + {docsReference.settingsHierarchy.length > 0 && ( +

+ Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')} +

+ )} +
+
+
+
+
+
+
+ ); + }; + + return ( +
+ + +
{renderOverview()}
+
+ + + + + { + setRawEditorDraftText(next); + }} + onSave={handleSaveRawConfig} + onRefresh={refreshAll} + language="toml" + loadingLabel="Loading config.toml..." + parseWarningLabel="TOML warning" + ownershipNotice={ +
+

This file is upstream-owned by Codex CLI.

+

+ CCS does not keep ~/.codex/config.toml in sync for you. +

+

+ CCS-backed Codex launches may apply transient -c overrides and + CCS_CODEX_API_KEY; those effective values may not appear here. +

+
+ } + /> +
+
+
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index 46d59ace..07895bd8 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -20,4 +20,6 @@ export { ClaudeExtensionPage } from './claude-extension'; export { UpdatesPage } from './updates'; +export { CodexPage } from './codex'; + export { DroidPage } from './droid'; diff --git a/ui/tests/unit/components/ui/code-editor.test.tsx b/ui/tests/unit/components/ui/code-editor.test.tsx index e19f6df4..f3776638 100644 --- a/ui/tests/unit/components/ui/code-editor.test.tsx +++ b/ui/tests/unit/components/ui/code-editor.test.tsx @@ -55,4 +55,16 @@ describe('CodeEditor', () => { expect(container.querySelector('[data-slot="code-editor-viewport"]')).not.toBeInTheDocument(); }); + + it('validates TOML payloads when language is toml', () => { + render( + + ); + + expect(screen.getByText('Valid TOML')).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts b/ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts index 170f3890..e899fddd 100644 --- a/ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts +++ b/ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts @@ -17,6 +17,7 @@ const layoutManagedRouteFiles = [ 'src/pages/copilot.tsx', 'src/pages/cursor.tsx', 'src/pages/claude-extension.tsx', + 'src/pages/codex.tsx', 'src/pages/droid.tsx', 'src/pages/accounts.tsx', 'src/pages/settings/index.tsx', @@ -26,13 +27,27 @@ const layoutManagedRouteFiles = [ const forbiddenViewportHeightPattern = /\b(?:h-screen|min-h-screen)\b|calc\(100(?:d|l|s)?vh/i; +function readSource(relativePath: string): string { + return readFileSync(path.join(projectRoot, relativePath), 'utf8'); +} + describe('dashboard route height contract', () => { it.each(layoutManagedRouteFiles)( '%s relies on the shared layout for viewport height', (relativePath) => { - const source = readFileSync(path.join(projectRoot, relativePath), 'utf8'); + const source = readSource(relativePath); expect(source).not.toMatch(forbiddenViewportHeightPattern); } ); + + it('keeps the Codex dashboard registered in router and sidebar navigation', () => { + const appSource = readSource('src/App.tsx'); + const sidebarSource = readSource('src/components/layout/app-sidebar.tsx'); + + expect(appSource).toContain('path="/codex"'); + expect(appSource).toContain(''); + expect(sidebarSource).toContain("path: '/codex'"); + expect(sidebarSource).toContain("label: 'Codex CLI'"); + }); }); From 3c52b1ab6d0ef8fccff14113c40c287320d5b547 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 20:49:18 -0400 Subject: [PATCH 05/10] fix(codex): align runtime compatibility and dashboard types --- src/ccs.ts | 77 +++++++---- src/targets/codex-adapter.ts | 4 +- src/targets/target-metadata.ts | 2 +- tests/unit/targets/codex-adapter.test.ts | 4 +- .../target-runtime-compatibility.test.ts | 7 + ui/src/hooks/use-codex-types.ts | 128 ++++++++++++++++++ ui/src/hooks/use-codex.ts | 5 +- 7 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 ui/src/hooks/use-codex-types.ts diff --git a/src/ccs.ts b/src/ccs.ts index 457536dd..ba962e88 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -87,6 +87,11 @@ interface DetectedProfile { remainingArgs: string[]; } +interface RuntimeReasoningResolution { + argsWithoutReasoningFlags: string[]; + reasoningOverride: string | number | undefined; +} + /** * Smart profile detection */ @@ -100,6 +105,42 @@ function detectProfile(args: string[]): DetectedProfile { } } +function resolveRuntimeReasoningFlags( + args: string[], + envThinkingValue: string | undefined +): RuntimeReasoningResolution { + const runtime = resolveDroidReasoningRuntime(args, envThinkingValue); + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + + return { + argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags, + reasoningOverride: runtime.reasoningOverride, + }; +} + +function exitWithRuntimeReasoningFlagError( + message: string, + options: { + codexAliasLevels: string; + includeDroidExecExample?: boolean; + } +): never { + console.error(fail(message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(` Codex alias: --effort ${options.codexAliasLevels}`); + if (options.includeDroidExecExample) { + console.error(' Droid exec: --reasoning-effort high'); + } + process.exit(1); +} + // ========== Main Execution ========== interface ProfileError extends Error { @@ -463,17 +504,9 @@ async function main(): Promise { targetRemainingArgs = droidRoute.argsForDroid; if (droidRoute.mode === 'interactive') { - const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); targetRemainingArgs = runtime.argsWithoutReasoningFlags; runtimeReasoningOverride = runtime.reasoningOverride; - - if (runtime.duplicateDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` - ) - ); - } } else { if (droidRoute.duplicateReasoningDisplays.length > 0) { console.error( @@ -490,33 +523,23 @@ async function main(): Promise { } } catch (error) { if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { - console.error(fail(error.message)); - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(' Codex alias: --effort medium|high|xhigh'); - console.error(' Droid exec: --reasoning-effort high'); - process.exit(1); + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'medium|high|xhigh', + includeDroidExecExample: true, + }); } throw error; } } else if (resolvedTarget === 'codex') { try { - const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); targetRemainingArgs = runtime.argsWithoutReasoningFlags; runtimeReasoningOverride = runtime.reasoningOverride; - - if (runtime.duplicateDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` - ) - ); - } } catch (error) { if (error instanceof DroidReasoningFlagError) { - console.error(fail(error.message)); - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(' Codex alias: --effort minimal|low|medium|high|xhigh'); - process.exit(1); + exitWithRuntimeReasoningFlagError(error.message, { + codexAliasLevels: 'minimal|low|medium|high|xhigh', + }); } throw error; } diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 1fb24b60..eedaf554 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -229,6 +229,8 @@ export class CodexAdapter implements TargetAdapter { } supportsProfileType(profileType: ProfileType): boolean { - return profileType === 'default' || profileType === 'settings' || profileType === 'cliproxy'; + // 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'; } } diff --git a/src/targets/target-metadata.ts b/src/targets/target-metadata.ts index af28aeb4..1c0ae30b 100644 --- a/src/targets/target-metadata.ts +++ b/src/targets/target-metadata.ts @@ -25,7 +25,7 @@ export const TARGET_METADATA: Record = { legacyAliasEnvVar: 'CCS_CODEX_ALIASES', persistedTarget: false, }, -}; +} satisfies Record; export const RUNTIME_TARGET_TYPES = Object.freeze( Object.keys(TARGET_METADATA) as TargetType[] diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index d74b384b..0889219b 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -5,10 +5,10 @@ import { CodexAdapter } from '../../../src/targets/codex-adapter'; describe('CodexAdapter', () => { const adapter = new CodexAdapter(); - test('supports default, settings, and cliproxy profile types', () => { + test('supports only adapter-level default and cliproxy profile types', () => { expect(adapter.supportsProfileType('default')).toBe(true); - expect(adapter.supportsProfileType('settings')).toBe(true); expect(adapter.supportsProfileType('cliproxy')).toBe(true); + expect(adapter.supportsProfileType('settings')).toBe(false); expect(adapter.supportsProfileType('account')).toBe(false); expect(adapter.supportsProfileType('copilot')).toBe(false); }); diff --git a/tests/unit/targets/target-runtime-compatibility.test.ts b/tests/unit/targets/target-runtime-compatibility.test.ts index cc169ec6..e75a0600 100644 --- a/tests/unit/targets/target-runtime-compatibility.test.ts +++ b/tests/unit/targets/target-runtime-compatibility.test.ts @@ -60,6 +60,13 @@ describe('evaluateTargetRuntimeCompatibility', () => { }); expect(compatibility.supported).toBe(false); expect(compatibility.reason).toMatch(/only supports CLIProxy Codex bridge profiles/); + + const genericSettingsCompatibility = evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'settings', + }); + expect(genericSettingsCompatibility.supported).toBe(false); + expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/); }); test('rejects account and copilot profiles on Codex target', () => { diff --git a/ui/src/hooks/use-codex-types.ts b/ui/src/hooks/use-codex-types.ts new file mode 100644 index 00000000..64ddba94 --- /dev/null +++ b/ui/src/hooks/use-codex-types.ts @@ -0,0 +1,128 @@ +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider' | 'openai' | 'github'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + +export interface CodexBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: 'CCS_CODEX_PATH' | 'PATH' | 'missing'; + version: string | null; + overridePath: string | null; + supportsConfigOverrides: boolean; +} + +export interface CodexConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface CodexModelProviderDiagnostics { + name: string; + baseUrl: string | null; + envKey: string | null; + wireApi: string | null; + requiresOpenaiAuth: boolean; + supportsWebsockets: boolean; + hasQueryParams: boolean; + hasHttpHeaders: boolean; + usesExperimentalBearerToken: boolean; +} + +export interface CodexFeatureFlagDiagnostics { + name: string; + state: 'enabled' | 'disabled' | 'custom'; +} + +export interface CodexProjectTrustDiagnostics { + path: string; + trustLevel: string; +} + +export interface CodexMcpServerDiagnostics { + name: string; + transport: 'stdio' | 'streamable-http' | 'unknown'; + enabled: boolean; + required: boolean; + startupTimeoutSec: number | null; + toolTimeoutSec: number | null; + enabledToolsCount: number; + disabledToolsCount: number; + usesInlineBearerToken: boolean; +} + +export interface CodexSupportMatrixEntry { + id: string; + label: string; + supported: boolean; + notes: string; +} + +export interface CodexUserConfigDiagnostics { + model: string | null; + modelProvider: string | null; + activeProfile: string | null; + approvalPolicy: string | null; + sandboxMode: string | null; + webSearch: string | null; + topLevelKeys: string[]; + profileCount: number; + profileNames: string[]; + modelProviderCount: number; + modelProviders: CodexModelProviderDiagnostics[]; + featureCount: number; + enabledFeatures: CodexFeatureFlagDiagnostics[]; + disabledFeatures: CodexFeatureFlagDiagnostics[]; + trustedProjectCount: number; + untrustedProjectCount: number; + projectTrust: CodexProjectTrustDiagnostics[]; + mcpServerCount: number; + mcpServers: CodexMcpServerDiagnostics[]; +} + +export interface CodexDashboardDiagnostics { + binary: CodexBinaryDiagnostics; + file: CodexConfigFileDiagnostics; + config: CodexUserConfigDiagnostics; + supportMatrix: CodexSupportMatrixEntry[]; + warnings: string[]; + docsReference: CompatibleCliDocsReference; +} + +export interface CodexRawConfigResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + config: Record | null; + parseError: string | null; +} diff --git a/ui/src/hooks/use-codex.ts b/ui/src/hooks/use-codex.ts index f486d571..ae37799d 100644 --- a/ui/src/hooks/use-codex.ts +++ b/ui/src/hooks/use-codex.ts @@ -2,10 +2,7 @@ import { useMemo } from 'react'; import { parse as parseToml } from 'smol-toml'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApiConflictError, withApiBase } from '@/lib/api-client'; -import type { - CodexDashboardDiagnostics, - CodexRawConfigResponse, -} from '../../../src/web-server/services/compatible-cli-types'; +import type { CodexDashboardDiagnostics, CodexRawConfigResponse } from './use-codex-types'; type CodexRawConfig = CodexRawConfigResponse; From b47aa0d28d55260e0db6d0e12791ea4a57fcd54b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 10:52:31 -0400 Subject: [PATCH 06/10] feat(codex): add dashboard control center - add guided editors for top-level settings, trust, profiles, providers, MCP, and features - refresh raw snapshots after patch saves to avoid stale mtime conflicts - block structured saves while raw TOML is dirty and add route plus hook coverage --- src/web-server/routes/codex-routes.ts | 30 ++ .../services/codex-dashboard-service.ts | 488 +++++++++++++++++- .../compatible-cli-toml-file-service.ts | 11 +- .../services/compatible-cli-types.ts | 84 +++ .../codex-dashboard-service.test.ts | 169 ++++++ tests/unit/web-server/codex-routes.test.ts | 142 +++++ .../codex-config-card-shell.tsx | 42 ++ .../compatible-cli/codex-features-card.tsx | 129 +++++ .../compatible-cli/codex-mcp-servers-card.tsx | 278 ++++++++++ .../codex-model-providers-card.tsx | 209 ++++++++ .../compatible-cli/codex-profiles-card.tsx | 250 +++++++++ .../codex-project-trust-card.tsx | 141 +++++ .../codex-top-level-controls-card.tsx | 280 ++++++++++ ui/src/hooks/use-codex-types.ts | 84 +++ ui/src/hooks/use-codex.ts | 38 +- ui/src/lib/codex-config.ts | 225 ++++++++ ui/src/pages/codex.tsx | 274 ++++++++-- ui/tests/unit/hooks/use-codex.test.tsx | 142 +++++ 18 files changed, 2959 insertions(+), 57 deletions(-) create mode 100644 tests/unit/web-server/codex-routes.test.ts create mode 100644 ui/src/components/compatible-cli/codex-config-card-shell.tsx create mode 100644 ui/src/components/compatible-cli/codex-features-card.tsx create mode 100644 ui/src/components/compatible-cli/codex-mcp-servers-card.tsx create mode 100644 ui/src/components/compatible-cli/codex-model-providers-card.tsx create mode 100644 ui/src/components/compatible-cli/codex-profiles-card.tsx create mode 100644 ui/src/components/compatible-cli/codex-project-trust-card.tsx create mode 100644 ui/src/components/compatible-cli/codex-top-level-controls-card.tsx create mode 100644 ui/src/lib/codex-config.ts create mode 100644 ui/tests/unit/hooks/use-codex.test.tsx diff --git a/src/web-server/routes/codex-routes.ts b/src/web-server/routes/codex-routes.ts index 83cc5f7e..e7abaf84 100644 --- a/src/web-server/routes/codex-routes.ts +++ b/src/web-server/routes/codex-routes.ts @@ -5,6 +5,7 @@ import { CodexRawConfigValidationError, getCodexDashboardDiagnostics, getCodexRawConfig, + patchCodexConfig, saveCodexRawConfig, } from '../services/codex-dashboard-service'; @@ -56,4 +57,33 @@ router.put('/config/raw', async (req: Request, res: Response): Promise => } }); +router.patch('/config/patch', async (req: Request, res: Response): Promise => { + try { + const body = req.body ?? {}; + if (typeof body.kind !== 'string' || body.kind.trim().length === 0) { + res.status(400).json({ error: 'kind is required.' }); + return; + } + if ( + body.expectedMtime !== undefined && + (typeof body.expectedMtime !== 'number' || !Number.isFinite(body.expectedMtime)) + ) { + res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' }); + return; + } + + res.json(await patchCodexConfig(body)); + } catch (error) { + if (error instanceof CodexRawConfigValidationError) { + res.status(400).json({ error: error.message }); + return; + } + if (error instanceof CodexRawConfigConflictError) { + res.status(409).json({ error: error.message, mtime: error.mtime }); + return; + } + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index 244f4bae..ca98b630 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -6,6 +6,8 @@ import { getCodexBinaryInfo, } from '../../targets/codex-detector'; import type { + CodexConfigPatchInput, + CodexConfigPatchResult, CodexDashboardDiagnostics, CodexFeatureFlagDiagnostics, CodexMcpServerDiagnostics, @@ -18,6 +20,7 @@ import { TomlFileConflictError, TomlFileValidationError, probeTomlObjectFile, + stringifyTomlObject, writeTomlFileAtomic, } from './compatible-cli-toml-file-service'; import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; @@ -44,6 +47,32 @@ export { TomlFileValidationError as CodexRawConfigValidationError, }; +const KNOWN_CODEX_FEATURES = new Set([ + 'apps', + 'apply_patch_freeform', + 'codex_hooks', + 'fast_mode', + 'js_repl', + 'multi_agent', + 'personality', + 'prevent_idle_sleep', + 'runtime_metrics', + 'shell_snapshot', + 'shell_tool', + 'smart_approvals', + 'unified_exec', + 'undo', + 'web_search', + 'web_search_cached', + 'web_search_request', +]); +const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); +const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']); +const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']); +const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']); +const PERSONALITY_VALUES = new Set(['default', 'pragmatic', 'concise', 'direct']); +const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'ask']); + function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -60,10 +89,409 @@ function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } -function hasOwn(obj: Record, key: string): boolean { +function hasOwn(obj: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(obj, key); } +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function ensureObject(target: Record, key: string): Record { + const existing = asObject(target[key]); + if (existing) return existing; + + const next: Record = {}; + target[key] = next; + return next; +} + +function deleteIfEmpty(target: Record, key: string) { + const value = asObject(target[key]); + if (value && Object.keys(value).length === 0) { + delete target[key]; + } +} + +function setStringField(target: Record, key: string, value: unknown) { + if (!isNonEmptyString(value)) { + delete target[key]; + return; + } + target[key] = value.trim(); +} + +function setEnumStringField( + target: Record, + key: string, + value: unknown, + allowedValues: Set, + label: string +) { + if (!isNonEmptyString(value)) { + delete target[key]; + return; + } + + const normalized = value.trim(); + const currentValue = asString(target[key]); + if (!allowedValues.has(normalized) && normalized !== currentValue) { + throw new TomlFileValidationError( + `${label} must be one of: ${Array.from(allowedValues).join(', ')}.` + ); + } + + target[key] = normalized; +} + +function setBooleanField(target: Record, key: string, value: unknown) { + if (typeof value !== 'boolean') { + delete target[key]; + return; + } + target[key] = value; +} + +function setNumberField( + target: Record, + key: string, + value: unknown, + options: { integer?: boolean; min?: number } = {} +) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + delete target[key]; + return; + } + + if (options.integer && !Number.isInteger(value)) { + throw new TomlFileValidationError(`${key} must be an integer.`); + } + if (typeof options.min === 'number' && value < options.min) { + throw new TomlFileValidationError(`${key} must be >= ${options.min}.`); + } + + target[key] = value; +} + +function normalizeStringArray(value: unknown, label: string): string[] | null { + if (value === null || value === undefined) return null; + if (!Array.isArray(value)) { + throw new TomlFileValidationError(`${label} must be an array of strings.`); + } + + const normalized = value + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry) => entry.length > 0); + return normalized.length > 0 ? normalized : []; +} + +function assertPatchableToml(fileProbe: { + diagnostics: { parseError: string | null; readError: string | null }; + config: Record | null; +}): Record { + if (fileProbe.diagnostics.readError) { + throw new TomlFileValidationError(fileProbe.diagnostics.readError); + } + if (fileProbe.diagnostics.parseError) { + throw new TomlFileValidationError( + 'config.toml contains invalid TOML. Fix the raw file before using guided controls.' + ); + } + return asObject(fileProbe.config) ?? {}; +} + +function applyTopLevelSettingsPatch( + target: Record, + values: Extract['values'] +) { + if (hasOwn(values, 'model')) setStringField(target, 'model', values.model); + if (hasOwn(values, 'modelReasoningEffort')) { + setEnumStringField( + target, + 'model_reasoning_effort', + values.modelReasoningEffort, + MODEL_REASONING_EFFORT_VALUES, + 'model_reasoning_effort' + ); + } + if (hasOwn(values, 'modelProvider')) { + setStringField(target, 'model_provider', values.modelProvider); + } + if (hasOwn(values, 'approvalPolicy')) { + setEnumStringField( + target, + 'approval_policy', + values.approvalPolicy, + APPROVAL_POLICY_VALUES, + 'approval_policy' + ); + } + if (hasOwn(values, 'sandboxMode')) { + setEnumStringField( + target, + 'sandbox_mode', + values.sandboxMode, + SANDBOX_MODE_VALUES, + 'sandbox_mode' + ); + } + if (hasOwn(values, 'webSearch')) { + setEnumStringField(target, 'web_search', values.webSearch, WEB_SEARCH_VALUES, 'web_search'); + } + if (hasOwn(values, 'toolOutputTokenLimit')) { + setNumberField(target, 'tool_output_token_limit', values.toolOutputTokenLimit, { + integer: true, + min: 1, + }); + } + if (hasOwn(values, 'personality')) { + setEnumStringField( + target, + 'personality', + values.personality, + PERSONALITY_VALUES, + 'personality' + ); + } +} + +function applyProjectTrustPatch( + target: Record, + input: Extract +) { + if (!isNonEmptyString(input.path)) { + throw new TomlFileValidationError('Project path is required.'); + } + + const expandedPath = expandPath(input.path.trim()); + if (!path.isAbsolute(expandedPath)) { + throw new TomlFileValidationError('Project path must be absolute or use ~/... expansion.'); + } + const canonicalPath = path.resolve(expandedPath); + const projects = ensureObject(target, 'projects'); + + if (!isNonEmptyString(input.trustLevel)) { + delete projects[canonicalPath]; + deleteIfEmpty(target, 'projects'); + return; + } + + const trustLevel = input.trustLevel.trim(); + if (!PROJECT_TRUST_LEVEL_VALUES.has(trustLevel)) { + throw new TomlFileValidationError( + `trust_level must be one of: ${Array.from(PROJECT_TRUST_LEVEL_VALUES).join(', ')}.` + ); + } + + projects[canonicalPath] = { + ...(asObject(projects[canonicalPath]) ?? {}), + trust_level: trustLevel, + }; +} + +function applyFeaturePatch( + target: Record, + input: Extract +) { + const feature = input.feature.trim(); + const currentFeatures = asObject(target.features); + if ( + !feature || + (!KNOWN_CODEX_FEATURES.has(feature) && !(currentFeatures && hasOwn(currentFeatures, feature))) + ) { + throw new TomlFileValidationError(`Unsupported feature key "${input.feature}".`); + } + if (input.enabled !== null && typeof input.enabled !== 'boolean') { + throw new TomlFileValidationError('Feature enabled must be boolean or null.'); + } + + const features = ensureObject(target, 'features'); + if (input.enabled === null) { + delete features[feature]; + } else { + features[feature] = input.enabled; + } + deleteIfEmpty(target, 'features'); +} + +function applyProfilePatch( + target: Record, + input: Extract +) { + if (!isNonEmptyString(input.name)) { + throw new TomlFileValidationError('Profile name is required.'); + } + + const profileName = input.name.trim(); + + if (!['set-active', 'upsert', 'delete'].includes(input.action)) { + throw new TomlFileValidationError('Unsupported profile action.'); + } + + if (input.action === 'set-active') { + setStringField(target, 'profile', profileName); + return; + } + + const profiles = ensureObject(target, 'profiles'); + if (input.action === 'delete') { + delete profiles[profileName]; + if (asString(target.profile) === profileName) { + delete target.profile; + } + deleteIfEmpty(target, 'profiles'); + return; + } + + const nextProfile = { ...(asObject(profiles[profileName]) ?? {}) }; + applyTopLevelSettingsPatch(nextProfile, input.values ?? {}); + if (Object.keys(nextProfile).length === 0) { + throw new TomlFileValidationError('Profile patch must include at least one saved field.'); + } + profiles[profileName] = nextProfile; + if (input.setAsActive === true) { + target.profile = profileName; + } +} + +function applyModelProviderPatch( + target: Record, + input: Extract +) { + if (!isNonEmptyString(input.name)) { + throw new TomlFileValidationError('Model provider name is required.'); + } + const providerName = input.name.trim(); + const providers = ensureObject(target, 'model_providers'); + + if (!['upsert', 'delete'].includes(input.action)) { + throw new TomlFileValidationError('Unsupported model provider action.'); + } + + if (input.action === 'delete') { + delete providers[providerName]; + if (asString(target.model_provider) === providerName) { + delete target.model_provider; + } + deleteIfEmpty(target, 'model_providers'); + return; + } + + const values = input.values; + if (!values) { + throw new TomlFileValidationError('Model provider values are required.'); + } + + const nextProvider = { ...(asObject(providers[providerName]) ?? {}) }; + if (hasOwn(values, 'displayName')) setStringField(nextProvider, 'name', values.displayName); + if (hasOwn(values, 'baseUrl')) setStringField(nextProvider, 'base_url', values.baseUrl); + if (hasOwn(values, 'envKey')) setStringField(nextProvider, 'env_key', values.envKey); + if (hasOwn(values, 'wireApi')) { + if (values.wireApi !== null && values.wireApi !== undefined && values.wireApi !== 'responses') { + throw new TomlFileValidationError('wire_api must be "responses" for Codex model providers.'); + } + setStringField(nextProvider, 'wire_api', values.wireApi); + } + if (hasOwn(values, 'requiresOpenaiAuth')) { + setBooleanField(nextProvider, 'requires_openai_auth', values.requiresOpenaiAuth); + } + if (hasOwn(values, 'supportsWebsockets')) { + setBooleanField(nextProvider, 'supports_websockets', values.supportsWebsockets); + } + + if (Object.keys(nextProvider).length === 0) { + throw new TomlFileValidationError( + 'Model provider patch must include at least one saved field.' + ); + } + providers[providerName] = nextProvider; +} + +function applyMcpServerPatch( + target: Record, + input: Extract +) { + if (!isNonEmptyString(input.name)) { + throw new TomlFileValidationError('MCP server name is required.'); + } + + const serverName = input.name.trim(); + const servers = ensureObject(target, 'mcp_servers'); + + if (!['upsert', 'delete'].includes(input.action)) { + throw new TomlFileValidationError('Unsupported MCP server action.'); + } + + if (input.action === 'delete') { + delete servers[serverName]; + deleteIfEmpty(target, 'mcp_servers'); + return; + } + + const values = input.values; + if (!values) { + throw new TomlFileValidationError('MCP server values are required.'); + } + if (values.transport !== 'stdio' && values.transport !== 'streamable-http') { + throw new TomlFileValidationError('MCP transport must be "stdio" or "streamable-http".'); + } + + const nextServer = { ...(asObject(servers[serverName]) ?? {}) }; + if (values.transport === 'stdio') { + if (!isNonEmptyString(values.command)) { + throw new TomlFileValidationError('Stdio MCP servers require a command.'); + } + nextServer.command = values.command.trim(); + const nextArgs = normalizeStringArray(values.args, 'args'); + if (nextArgs === null) { + delete nextServer.args; + } else { + nextServer.args = nextArgs; + } + delete nextServer.url; + } else { + if (!isNonEmptyString(values.url)) { + throw new TomlFileValidationError('HTTP MCP servers require a URL.'); + } + nextServer.url = values.url.trim(); + delete nextServer.command; + delete nextServer.args; + } + + if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled); + if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required); + if (hasOwn(values, 'startupTimeoutSec')) { + setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, { + integer: true, + min: 1, + }); + } + if (hasOwn(values, 'toolTimeoutSec')) { + setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, { + integer: true, + min: 1, + }); + } + if (hasOwn(values, 'enabledTools')) { + const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools'); + if (nextEnabledTools === null) { + delete nextServer.enabled_tools; + } else { + nextServer.enabled_tools = nextEnabledTools; + } + } + if (hasOwn(values, 'disabledTools')) { + const nextDisabledTools = normalizeStringArray(values.disabledTools, 'disabledTools'); + if (nextDisabledTools === null) { + delete nextServer.disabled_tools; + } else { + nextServer.disabled_tools = nextDisabledTools; + } + } + + servers[serverName] = nextServer; +} + function parseTransport(server: Record): CodexMcpServerDiagnostics['transport'] { if (asString(server.command)) return 'stdio'; if (asString(server.url)) return 'streamable-http'; @@ -294,13 +722,17 @@ export async function getCodexDashboardDiagnostics(): Promise { + const paths = resolveCodexConfigPaths(); + const fileProbe = await probeTomlObjectFile( + paths.configPath, + 'Codex user config', + paths.configDisplayPath + ); + const nextConfig = { ...assertPatchableToml(fileProbe) }; + + switch (input.kind) { + case 'top-level': + applyTopLevelSettingsPatch(nextConfig, input.values); + break; + case 'project-trust': + applyProjectTrustPatch(nextConfig, input); + break; + case 'feature': + applyFeaturePatch(nextConfig, input); + break; + case 'profile': + applyProfilePatch(nextConfig, input); + break; + case 'model-provider': + applyModelProviderPatch(nextConfig, input); + break; + case 'mcp-server': + applyMcpServerPatch(nextConfig, input); + break; + default: + throw new TomlFileValidationError('Unsupported Codex config patch.'); + } + + const rawText = stringifyTomlObject(nextConfig); + const saved = await writeTomlFileAtomic({ + filePath: paths.configPath, + rawText, + expectedMtime: input.expectedMtime, + fileLabel: 'config.toml', + }); + + return { + success: true, + path: paths.configDisplayPath, + resolvedPath: paths.configPath, + exists: true, + mtime: saved.mtime, + rawText, + config: nextConfig, + parseError: null, + }; +} diff --git a/src/web-server/services/compatible-cli-toml-file-service.ts b/src/web-server/services/compatible-cli-toml-file-service.ts index b7780711..312a04c3 100644 --- a/src/web-server/services/compatible-cli-toml-file-service.ts +++ b/src/web-server/services/compatible-cli-toml-file-service.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import { parse } from 'smol-toml'; +import { parse, stringify } from 'smol-toml'; export interface TomlFileDiagnostics { label: string; @@ -92,6 +92,15 @@ export function parseTomlObjectText( return parsed; } +export function stringifyTomlObject(config: Record): string { + if (!isObject(config)) { + throw new TomlFileValidationError('config TOML root must be a table.'); + } + + const text = stringify(config).trimEnd(); + return text ? `${text}\n` : ''; +} + export async function probeTomlObjectFile( filePath: string, label: string, diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts index e3c749bb..dd29fcc7 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -146,11 +146,14 @@ export interface CodexSupportMatrixEntry { export interface CodexUserConfigDiagnostics { model: string | null; + modelReasoningEffort: string | null; modelProvider: string | null; activeProfile: string | null; approvalPolicy: string | null; sandboxMode: string | null; webSearch: string | null; + toolOutputTokenLimit: number | null; + personality: string | null; topLevelKeys: string[]; profileCount: number; profileNames: string[]; @@ -169,6 +172,7 @@ export interface CodexUserConfigDiagnostics { export interface CodexDashboardDiagnostics { binary: CodexBinaryDiagnostics; file: CodexConfigFileDiagnostics; + workspacePath: string; config: CodexUserConfigDiagnostics; supportMatrix: CodexSupportMatrixEntry[]; warnings: string[]; @@ -184,3 +188,83 @@ export interface CodexRawConfigResponse { config: Record | null; parseError: string | null; } + +export interface CodexTopLevelSettingsPatch { + model?: string | null; + modelReasoningEffort?: string | null; + modelProvider?: string | null; + approvalPolicy?: string | null; + sandboxMode?: string | null; + webSearch?: string | null; + toolOutputTokenLimit?: number | null; + personality?: string | null; +} + +export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {} + +export interface CodexModelProviderPatchValues { + displayName?: string | null; + baseUrl?: string | null; + envKey?: string | null; + wireApi?: string | null; + requiresOpenaiAuth?: boolean | null; + supportsWebsockets?: boolean | null; +} + +export interface CodexMcpServerPatchValues { + transport: 'stdio' | 'streamable-http'; + command?: string | null; + args?: string[] | null; + url?: string | null; + enabled?: boolean | null; + required?: boolean | null; + startupTimeoutSec?: number | null; + toolTimeoutSec?: number | null; + enabledTools?: string[] | null; + disabledTools?: string[] | null; +} + +export type CodexConfigPatchInput = + | { + kind: 'top-level'; + expectedMtime?: number; + values: CodexTopLevelSettingsPatch; + } + | { + kind: 'project-trust'; + expectedMtime?: number; + path: string; + trustLevel: string | null; + } + | { + kind: 'feature'; + expectedMtime?: number; + feature: string; + enabled: boolean | null; + } + | { + kind: 'profile'; + expectedMtime?: number; + action: 'set-active' | 'upsert' | 'delete'; + name: string; + values?: CodexProfilePatchValues; + setAsActive?: boolean; + } + | { + kind: 'model-provider'; + expectedMtime?: number; + action: 'upsert' | 'delete'; + name: string; + values?: CodexModelProviderPatchValues; + } + | { + kind: 'mcp-server'; + expectedMtime?: number; + action: 'upsert' | 'delete'; + name: string; + values?: CodexMcpServerPatchValues; + }; + +export interface CodexConfigPatchResult extends CodexRawConfigResponse { + success: true; +} diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index bfbdf179..77e837c8 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -7,6 +7,7 @@ import { CodexRawConfigValidationError, getCodexDashboardDiagnostics, getCodexRawConfig, + patchCodexConfig, resolveCodexConfigPaths, saveCodexRawConfig, summarizeCodexFeatureFlags, @@ -262,4 +263,172 @@ bearer_token = "secret" }) ).rejects.toThrow(CodexRawConfigConflictError); }); + + it('patches top-level settings and project trust through structured controls', async () => { + const result = await patchCodexConfig({ + kind: 'top-level', + values: { + model: 'gpt-5.4', + modelReasoningEffort: 'high', + approvalPolicy: 'never', + sandboxMode: 'workspace-write', + webSearch: 'cached', + toolOutputTokenLimit: 12000, + personality: 'pragmatic', + }, + }); + + await patchCodexConfig({ + kind: 'project-trust', + path: '/tmp/workspace-a', + trustLevel: 'trusted', + expectedMtime: result.mtime, + }); + + const diagnostics = await getCodexDashboardDiagnostics(); + expect(diagnostics.config.model).toBe('gpt-5.4'); + expect(diagnostics.config.modelReasoningEffort).toBe('high'); + expect(diagnostics.config.toolOutputTokenLimit).toBe(12000); + expect(diagnostics.config.personality).toBe('pragmatic'); + expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a'); + expect(result.rawText).toContain('model = "gpt-5.4"'); + expect(result.config?.model).toBe('gpt-5.4'); + }); + + it('expands home paths for project trust and rejects relative paths', async () => { + const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace'); + const expanded = await patchCodexConfig({ + kind: 'project-trust', + path: '~/codex-workspace', + trustLevel: 'trusted', + }); + + expect(expanded.rawText).toContain(`[projects."${homeWorkspacePath}"]`); + + await expect( + patchCodexConfig({ + kind: 'project-trust', + path: './relative-workspace', + trustLevel: 'trusted', + }) + ).rejects.toThrow(CodexRawConfigValidationError); + }); + + it('patches profiles, providers, and mcp servers through structured controls', async () => { + const providerResult = await patchCodexConfig({ + kind: 'model-provider', + action: 'upsert', + name: 'cliproxy', + values: { + displayName: 'CLIProxy', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + envKey: 'CLIPROXY_API_KEY', + wireApi: 'responses', + }, + }); + + const profileResult = await patchCodexConfig({ + kind: 'profile', + action: 'upsert', + name: 'deep-review', + values: { + model: 'gpt-5.4', + modelProvider: 'cliproxy', + modelReasoningEffort: 'xhigh', + }, + setAsActive: true, + expectedMtime: providerResult.mtime, + }); + + await patchCodexConfig({ + kind: 'mcp-server', + action: 'upsert', + name: 'playwright', + values: { + transport: 'stdio', + command: 'npx', + args: ['@playwright/mcp@latest'], + enabled: true, + required: false, + startupTimeoutSec: 15, + toolTimeoutSec: 30, + }, + expectedMtime: profileResult.mtime, + }); + + const diagnostics = await getCodexDashboardDiagnostics(); + expect(diagnostics.config.activeProfile).toBe('deep-review'); + expect(diagnostics.config.modelProviderCount).toBe(1); + expect(diagnostics.config.mcpServerCount).toBe(1); + + const raw = await getCodexRawConfig(); + expect(raw.rawText).toContain('[profiles.deep-review]'); + expect(raw.rawText).toContain('[model_providers.cliproxy]'); + expect(raw.rawText).toContain('[mcp_servers.playwright]'); + expect(profileResult.rawText).toContain('[profiles.deep-review]'); + expect(profileResult.config?.profile).toBe('deep-review'); + }); + + it('rejects structured patches when config.toml is invalid', async () => { + fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n'); + + await expect( + patchCodexConfig({ + kind: 'feature', + feature: 'multi_agent', + enabled: true, + }) + ).rejects.toThrow(CodexRawConfigValidationError); + }); + + it('removes feature overrides when a feature is reset to inherited state', async () => { + const enabled = await patchCodexConfig({ + kind: 'feature', + feature: 'multi_agent', + enabled: true, + }); + + expect(enabled.rawText).toContain('[features]'); + expect(enabled.rawText).toContain('multi_agent = true'); + + const reset = await patchCodexConfig({ + kind: 'feature', + feature: 'multi_agent', + enabled: null, + expectedMtime: enabled.mtime, + }); + + expect(reset.rawText).not.toContain('[features]'); + expect(reset.config?.features).toBeUndefined(); + }); + + it('rejects malformed structured patch payloads at runtime', async () => { + await expect( + patchCodexConfig({ + kind: 'feature', + feature: 'multi_agent', + enabled: 'true' as unknown as boolean | null, + }) + ).rejects.toThrow(CodexRawConfigValidationError); + + await expect( + patchCodexConfig({ + kind: 'project-trust', + path: '~/codex-workspace', + trustLevel: 'always', + }) + ).rejects.toThrow(CodexRawConfigValidationError); + + await expect( + patchCodexConfig({ + kind: 'mcp-server', + action: 'upsert', + name: 'remote', + values: { + transport: 'http' as 'stdio' | 'streamable-http', + url: 'https://example.test/mcp', + }, + }) + ).rejects.toThrow(CodexRawConfigValidationError); + }); }); diff --git a/tests/unit/web-server/codex-routes.test.ts b/tests/unit/web-server/codex-routes.test.ts new file mode 100644 index 00000000..78083ef9 --- /dev/null +++ b/tests/unit/web-server/codex-routes.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; + +let server: Server; +let baseUrl = ''; +let tempDir = ''; +let codexHome = ''; +let originalCodexHome: string | undefined; + +beforeAll(async () => { + originalCodexHome = process.env.CODEX_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-routes-test-')); + codexHome = path.join(tempDir, '.codex-home'); + process.env.CODEX_HOME = codexHome; + + const codexRoutesModule = await import('../../../src/web-server/routes/codex-routes'); + + const app = express(); + app.use(express.json()); + app.use('/api/codex', codexRoutesModule.default); + + server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.on('listening', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +beforeEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(codexHome, { recursive: true }); +}); + +afterAll(async () => { + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('codex routes', () => { + it('returns the current raw config snapshot from PATCH /config/patch', async () => { + const res = await fetch(`${baseUrl}/api/codex/config/patch`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'top-level', + values: { + model: 'gpt-5.4', + sandboxMode: 'workspace-write', + }, + }), + }); + + expect(res.status).toBe(200); + + const json = (await res.json()) as { + success: boolean; + exists: boolean; + mtime: number; + rawText: string; + config: Record | null; + parseError: string | null; + }; + + expect(json.success).toBe(true); + expect(json.exists).toBe(true); + expect(json.mtime).toBeGreaterThan(0); + expect(json.parseError).toBeNull(); + expect(json.rawText).toContain('model = "gpt-5.4"'); + expect(json.rawText).toContain('sandbox_mode = "workspace-write"'); + expect(json.config?.model).toBe('gpt-5.4'); + + const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + expect(written).toBe(json.rawText); + }); + + it('returns 409 when PATCH /config/patch receives a stale expectedMtime', async () => { + const configPath = path.join(codexHome, 'config.toml'); + fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n'); + + const res = await fetch(`${baseUrl}/api/codex/config/patch`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'feature', + feature: 'multi_agent', + enabled: true, + expectedMtime: 1, + }), + }); + + expect(res.status).toBe(409); + + const json = (await res.json()) as { error: string; mtime: number }; + expect(json.error).toContain('File modified externally.'); + expect(json.mtime).toBeGreaterThan(0); + }); + + it('returns 400 when PATCH /config/patch omits kind', async () => { + const res = await fetch(`${baseUrl}/api/codex/config/patch`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string }; + expect(json.error).toBe('kind is required.'); + }); + + it('returns 400 when PATCH /config/patch receives an invalid trust path', async () => { + const res = await fetch(`${baseUrl}/api/codex/config/patch`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'project-trust', + path: './relative-workspace', + trustLevel: 'trusted', + }), + }); + + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string }; + expect(json.error).toContain('Project path must be absolute'); + }); +}); diff --git a/ui/src/components/compatible-cli/codex-config-card-shell.tsx b/ui/src/components/compatible-cli/codex-config-card-shell.tsx new file mode 100644 index 00000000..f3375e22 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-config-card-shell.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface CodexConfigCardShellProps { + title: string; + icon?: ReactNode; + badge?: string; + description?: string; + disabledReason?: string | null; + children: ReactNode; +} + +export function CodexConfigCardShell({ + title, + icon, + badge, + description, + disabledReason, + children, +}: CodexConfigCardShellProps) { + return ( + + + + {icon} + {title} + {badge ? ( + + {badge} + + ) : null} + + {description ?

{description}

: null} +
+ + {disabledReason ?

{disabledReason}

: null} + {children} +
+
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-features-card.tsx b/ui/src/components/compatible-cli/codex-features-card.tsx new file mode 100644 index 00000000..1d7a95c1 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-features-card.tsx @@ -0,0 +1,129 @@ +import { Sparkles } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import type { CodexFeatureCatalogEntry } from '@/lib/codex-config'; +import { CodexConfigCardShell } from './codex-config-card-shell'; + +interface CodexFeaturesCardProps { + catalog: CodexFeatureCatalogEntry[]; + state: Record; + disabled?: boolean; + disabledReason?: string | null; + onToggle: (feature: string, enabled: boolean | null) => Promise | void; +} + +export function CodexFeaturesCard({ + catalog, + state, + disabled = false, + disabledReason, + onToggle, +}: CodexFeaturesCardProps) { + const knownFeatureNames = new Set(catalog.map((feature) => feature.name)); + const configOnlyFeatures = Object.entries(state) + .filter(([name]) => !knownFeatureNames.has(name)) + .sort(([left], [right]) => left.localeCompare(right)); + + return ( + } + description="Toggle the supported Codex feature flags CCS can safely manage." + disabledReason={disabledReason} + > +
+ {catalog.map((feature) => { + const current = state[feature.name] ?? null; + return ( +
+
+
+

{feature.label}

+ + {feature.name} + +
+

{feature.description}

+
+
+ {current !== null ? ( + + ) : null} + onToggle(feature.name, next)} + disabled={disabled} + /> +
+
+ ); + })} +
+ + {configOnlyFeatures.length > 0 ? ( +
+
+

+ Existing config-only flags +

+

+ These feature keys already exist in your `config.toml`, so CCS can surface them + without claiming full catalog coverage. +

+
+ {configOnlyFeatures.map(([name, current]) => ( +
+
+
+

{name}

+ + existing + +
+

+ {current === null + ? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.' + : "Discovered from the current file instead of CCS's built-in catalog."} +

+
+ {current === null ? ( + Raw only + ) : ( +
+ + onToggle(name, next)} + disabled={disabled} + /> +
+ )} +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx new file mode 100644 index 00000000..79f1aa39 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx @@ -0,0 +1,278 @@ +import { useMemo, useState } from 'react'; +import { Loader2, PlugZap, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { CodexMcpServerPatchValues } from '@/hooks/use-codex-types'; +import type { CodexMcpServerEntry } from '@/lib/codex-config'; +import { CodexConfigCardShell } from './codex-config-card-shell'; + +interface CodexMcpServersCardProps { + entries: CodexMcpServerEntry[]; + disabled?: boolean; + disabledReason?: string | null; + saving?: boolean; + onSave: (name: string, values: CodexMcpServerPatchValues) => Promise | void; + onDelete: (name: string) => Promise | void; +} + +const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = { + name: '', + transport: 'stdio', + command: null, + args: [], + url: null, + enabled: true, + required: false, + startupTimeoutSec: null, + toolTimeoutSec: null, + enabledTools: [], + disabledTools: [], +}; + +function toCsv(value: string[]) { + return value.join(', '); +} + +function fromCsv(value: string) { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +interface McpServerEditorProps { + initialDraft: CodexMcpServerEntry; + isNew: boolean; + disabled: boolean; + saving: boolean; + canDelete: boolean; + onSave: (name: string, values: CodexMcpServerPatchValues) => Promise | void; + onDelete: () => Promise | void; +} + +function McpServerEditor({ + initialDraft, + isNew, + disabled, + saving, + canDelete, + onSave, + onDelete, +}: McpServerEditorProps) { + const [draft, setDraft] = useState(initialDraft); + + return ( + <> +
+ setDraft((current) => ({ ...current, name: event.target.value }))} + placeholder="playwright" + disabled={disabled || !isNew} + /> + + {draft.transport === 'stdio' ? ( + <> + + setDraft((current) => ({ ...current, command: event.target.value || null })) + } + placeholder="npx" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, args: fromCsv(event.target.value) })) + } + placeholder="@playwright/mcp@latest, --flag" + disabled={disabled} + /> + + ) : ( + + setDraft((current) => ({ ...current, url: event.target.value || null })) + } + placeholder="https://example.test/mcp" + disabled={disabled} + /> + )} + + setDraft((current) => ({ + ...current, + startupTimeoutSec: event.target.value ? Number(event.target.value) : null, + })) + } + placeholder="Startup timeout (sec)" + disabled={disabled} + /> + + setDraft((current) => ({ + ...current, + toolTimeoutSec: event.target.value ? Number(event.target.value) : null, + })) + } + placeholder="Tool timeout (sec)" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, enabledTools: fromCsv(event.target.value) })) + } + placeholder="enabled_tools" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, disabledTools: fromCsv(event.target.value) })) + } + placeholder="disabled_tools" + disabled={disabled} + /> +
+ +
+ + +
+ +
+ + +
+ + ); +} + +export function CodexMcpServersCard({ + entries, + disabled = false, + disabledReason, + saving = false, + onSave, + onDelete, +}: CodexMcpServersCardProps) { + const [selectedName, setSelectedName] = useState('new'); + const selectedEntry = useMemo( + () => entries.find((entry) => entry.name === selectedName) ?? null, + [entries, selectedName] + ); + const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT; + const draftKey = JSON.stringify(draftSeed); + + return ( + } + description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML." + disabledReason={disabledReason} + > + + { + if (!selectedEntry) return; + await onDelete(selectedEntry.name); + setSelectedName('new'); + }} + onSave={async (name, values) => { + await onSave(name, values); + setSelectedName(name); + }} + /> + + ); +} diff --git a/ui/src/components/compatible-cli/codex-model-providers-card.tsx b/ui/src/components/compatible-cli/codex-model-providers-card.tsx new file mode 100644 index 00000000..7d3b9814 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-model-providers-card.tsx @@ -0,0 +1,209 @@ +import { useMemo, useState } from 'react'; +import { KeyRound, Loader2, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { CodexModelProviderPatchValues } from '@/hooks/use-codex-types'; +import type { CodexModelProviderEntry } from '@/lib/codex-config'; +import { CodexConfigCardShell } from './codex-config-card-shell'; + +interface CodexModelProvidersCardProps { + entries: CodexModelProviderEntry[]; + disabled?: boolean; + disabledReason?: string | null; + saving?: boolean; + onSave: (name: string, values: CodexModelProviderPatchValues) => Promise | void; + onDelete: (name: string) => Promise | void; +} + +const EMPTY_MODEL_PROVIDER_DRAFT: CodexModelProviderEntry = { + name: '', + displayName: null, + baseUrl: null, + envKey: null, + wireApi: 'responses', + requiresOpenaiAuth: false, + supportsWebsockets: false, +}; + +interface ModelProviderEditorProps { + initialDraft: CodexModelProviderEntry; + isNew: boolean; + disabled: boolean; + saving: boolean; + canDelete: boolean; + onSave: (name: string, values: CodexModelProviderPatchValues) => Promise | void; + onDelete: () => Promise | void; +} + +function ModelProviderEditor({ + initialDraft, + isNew, + disabled, + saving, + canDelete, + onSave, + onDelete, +}: ModelProviderEditorProps) { + const [draft, setDraft] = useState(initialDraft); + + return ( + <> +
+ setDraft((current) => ({ ...current, name: event.target.value }))} + placeholder="Provider id" + disabled={disabled || !isNew} + /> + + setDraft((current) => ({ ...current, displayName: event.target.value || null })) + } + placeholder="Display name" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, baseUrl: event.target.value || null })) + } + placeholder="http://127.0.0.1:8317/api/provider/codex" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, envKey: event.target.value || null })) + } + placeholder="CLIPROXY_API_KEY" + disabled={disabled} + /> +
+ +
+ + + +
+ +
+ + +
+ + ); +} + +export function CodexModelProvidersCard({ + entries, + disabled = false, + disabledReason, + saving = false, + onSave, + onDelete, +}: CodexModelProvidersCardProps) { + const [selectedName, setSelectedName] = useState('new'); + const selectedEntry = useMemo( + () => entries.find((entry) => entry.name === selectedName) ?? null, + [entries, selectedName] + ); + const draftSeed = selectedEntry ?? EMPTY_MODEL_PROVIDER_DRAFT; + const draftKey = JSON.stringify(draftSeed); + + return ( + } + description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML." + disabledReason={disabledReason} + > + + { + if (!selectedEntry) return; + await onDelete(selectedEntry.name); + setSelectedName('new'); + }} + onSave={async (name, values) => { + await onSave(name, values); + setSelectedName(name); + }} + /> + + ); +} diff --git a/ui/src/components/compatible-cli/codex-profiles-card.tsx b/ui/src/components/compatible-cli/codex-profiles-card.tsx new file mode 100644 index 00000000..f2e002a7 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-profiles-card.tsx @@ -0,0 +1,250 @@ +import { useMemo, useState } from 'react'; +import { Layers3, Loader2, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { CodexProfilePatchValues } from '@/hooks/use-codex-types'; +import type { CodexProfileEntry } from '@/lib/codex-config'; +import { CodexConfigCardShell } from './codex-config-card-shell'; + +interface CodexProfilesCardProps { + activeProfile: string | null; + entries: CodexProfileEntry[]; + providerNames: string[]; + disabled?: boolean; + disabledReason?: string | null; + saving?: boolean; + onSave: ( + name: string, + values: CodexProfilePatchValues, + setAsActive: boolean + ) => Promise | void; + onDelete: (name: string) => Promise | void; + onSetActive: (name: string) => Promise | void; +} + +interface ProfileEditorProps { + initialName: string; + initialModel: string | null; + initialProvider: string | null; + initialEffort: string | null; + providerNames: string[]; + activeProfile: string | null; + selectedEntryName: string | null; + disabled: boolean; + saving: boolean; + onSave: ( + name: string, + values: CodexProfilePatchValues, + setAsActive: boolean + ) => Promise | void; + onDelete: () => Promise | void; + onSetActive: () => Promise | void; +} + +function ProfileEditor({ + initialName, + initialModel, + initialProvider, + initialEffort, + providerNames, + activeProfile, + selectedEntryName, + disabled, + saving, + onSave, + onDelete, + onSetActive, +}: ProfileEditorProps) { + const [nameDraft, setNameDraft] = useState(initialName); + const [modelDraft, setModelDraft] = useState(initialModel); + const [providerDraft, setProviderDraft] = useState(initialProvider); + const [effortDraft, setEffortDraft] = useState(initialEffort); + + return ( + <> +
+ setNameDraft(event.target.value)} + placeholder="deep-review" + disabled={disabled || selectedEntryName !== null} + /> + setModelDraft(event.target.value || null)} + placeholder="gpt-5.4" + disabled={disabled} + /> + + +
+ +
+
+ + +
+
+ + +
+
+ + ); +} + +export function CodexProfilesCard({ + activeProfile, + entries, + providerNames, + disabled = false, + disabledReason, + saving = false, + onSave, + onDelete, + onSetActive, +}: CodexProfilesCardProps) { + const [selectedName, setSelectedName] = useState('new'); + const selectedEntry = useMemo( + () => entries.find((entry) => entry.name === selectedName) ?? null, + [entries, selectedName] + ); + const draftKey = JSON.stringify(selectedEntry ?? { name: '', values: {} }); + + return ( + } + description="Create reusable Codex overlays and set the active default profile." + disabledReason={disabledReason} + > + + { + if (!selectedEntry) return; + await onDelete(selectedEntry.name); + setSelectedName('new'); + }} + onSetActive={async () => { + if (!selectedEntry) return; + await onSetActive(selectedEntry.name); + }} + onSave={async (name, values, setAsActive) => { + await onSave(name, values, setAsActive); + setSelectedName(name); + }} + /> + + ); +} diff --git a/ui/src/components/compatible-cli/codex-project-trust-card.tsx b/ui/src/components/compatible-cli/codex-project-trust-card.tsx new file mode 100644 index 00000000..f9779f60 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-project-trust-card.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { FolderCheck, Loader2, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { CodexProjectTrustEntry } from '@/lib/codex-config'; +import { CodexConfigCardShell } from './codex-config-card-shell'; + +interface CodexProjectTrustCardProps { + workspacePath: string; + entries: CodexProjectTrustEntry[]; + disabled?: boolean; + disabledReason?: string | null; + saving?: boolean; + onSave: (path: string, trustLevel: string | null) => Promise | void; +} + +interface ProjectTrustComposerProps { + workspacePath: string; + disabled: boolean; + saving: boolean; + onSave: (path: string, trustLevel: string | null) => Promise | void; +} + +function ProjectTrustComposer({ + workspacePath, + disabled, + saving, + onSave, +}: ProjectTrustComposerProps) { + const [pathDraft, setPathDraft] = useState(workspacePath); + const [trustLevel, setTrustLevel] = useState('trusted'); + + return ( +
+ setPathDraft(event.target.value)} + placeholder="~/repo or /absolute/path" + disabled={disabled} + /> + + +
+ ); +} + +export function CodexProjectTrustCard({ + workspacePath, + entries, + disabled = false, + disabledReason, + saving = false, + onSave, +}: CodexProjectTrustCardProps) { + return ( + } + description="Trust current workspaces or remove stale trust entries without opening raw TOML." + disabledReason={disabledReason} + > +

+ Paths must be absolute or start with ~/. Relative paths are rejected so CCS + does not trust the wrong folder. +

+ + + + +
+ {entries.length === 0 ? ( +

No explicit project trust entries saved.

+ ) : ( + entries.map((entry) => ( +
+
+

{entry.path}

+

trust_level = {entry.trustLevel}

+
+
+ + +
+
+ )) + )} +
+
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx new file mode 100644 index 00000000..787724f6 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx @@ -0,0 +1,280 @@ +import { useState } from 'react'; +import { Loader2, SlidersHorizontal } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { CodexTopLevelSettingsPatch } from '@/hooks/use-codex-types'; +import type { CodexTopLevelSettingsView } from '@/lib/codex-config'; +import { CodexConfigCardShell } from './codex-config-card-shell'; + +const UNSET = '__unset__'; + +interface CodexTopLevelControlsCardProps { + values: CodexTopLevelSettingsView; + providerNames: string[]; + disabled?: boolean; + disabledReason?: string | null; + saving?: boolean; + onSave: (values: CodexTopLevelSettingsPatch) => Promise | void; +} + +function toSelectValue(value: string | null | undefined) { + return value ?? UNSET; +} + +function withCurrentValue(options: string[], current: string | null | undefined) { + return current && !options.includes(current) ? [current, ...options] : options; +} + +interface TopLevelControlsFormProps { + initialValues: CodexTopLevelSettingsView; + providerNames: string[]; + disabled: boolean; + saving: boolean; + onSave: (values: CodexTopLevelSettingsPatch) => Promise | void; +} + +function TopLevelControlsForm({ + initialValues, + providerNames, + disabled, + saving, + onSave, +}: TopLevelControlsFormProps) { + const [draft, setDraft] = useState(initialValues); + const reasoningOptions = withCurrentValue( + ['minimal', 'low', 'medium', 'high', 'xhigh'], + draft.modelReasoningEffort + ); + const providerOptions = withCurrentValue(providerNames, draft.modelProvider); + const approvalOptions = withCurrentValue( + ['on-request', 'never', 'untrusted'], + draft.approvalPolicy + ); + const sandboxOptions = withCurrentValue( + ['read-only', 'workspace-write', 'danger-full-access'], + draft.sandboxMode + ); + const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch); + const personalityOptions = withCurrentValue( + ['default', 'pragmatic', 'concise', 'direct'], + draft.personality + ); + + return ( + <> +
+
+

Model

+ + setDraft((current) => ({ ...current, model: event.target.value || null })) + } + placeholder="gpt-5.4" + disabled={disabled} + /> +
+ +
+

Reasoning effort

+ +
+ +
+

Default provider

+ +
+ +
+

Approval policy

+ +
+ +
+

Sandbox mode

+ +
+ +
+

Web search

+ +
+ +
+

Tool output token limit

+ + setDraft((current) => ({ + ...current, + toolOutputTokenLimit: event.target.value ? Number(event.target.value) : null, + })) + } + placeholder="25000" + disabled={disabled} + /> +
+ +
+

Personality

+ +
+
+ +
+ +
+ + ); +} + +export function CodexTopLevelControlsCard({ + values, + providerNames, + disabled = false, + disabledReason, + saving = false, + onSave, +}: CodexTopLevelControlsCardProps) { + return ( + } + description="Structured controls for the stable top-level Codex settings users touch most often." + disabledReason={disabledReason} + > + + + ); +} diff --git a/ui/src/hooks/use-codex-types.ts b/ui/src/hooks/use-codex-types.ts index 64ddba94..af554937 100644 --- a/ui/src/hooks/use-codex-types.ts +++ b/ui/src/hooks/use-codex-types.ts @@ -88,11 +88,14 @@ export interface CodexSupportMatrixEntry { export interface CodexUserConfigDiagnostics { model: string | null; + modelReasoningEffort: string | null; modelProvider: string | null; activeProfile: string | null; approvalPolicy: string | null; sandboxMode: string | null; webSearch: string | null; + toolOutputTokenLimit: number | null; + personality: string | null; topLevelKeys: string[]; profileCount: number; profileNames: string[]; @@ -111,6 +114,7 @@ export interface CodexUserConfigDiagnostics { export interface CodexDashboardDiagnostics { binary: CodexBinaryDiagnostics; file: CodexConfigFileDiagnostics; + workspacePath: string; config: CodexUserConfigDiagnostics; supportMatrix: CodexSupportMatrixEntry[]; warnings: string[]; @@ -126,3 +130,83 @@ export interface CodexRawConfigResponse { config: Record | null; parseError: string | null; } + +export interface CodexTopLevelSettingsPatch { + model?: string | null; + modelReasoningEffort?: string | null; + modelProvider?: string | null; + approvalPolicy?: string | null; + sandboxMode?: string | null; + webSearch?: string | null; + toolOutputTokenLimit?: number | null; + personality?: string | null; +} + +export type CodexProfilePatchValues = CodexTopLevelSettingsPatch; + +export interface CodexModelProviderPatchValues { + displayName?: string | null; + baseUrl?: string | null; + envKey?: string | null; + wireApi?: string | null; + requiresOpenaiAuth?: boolean | null; + supportsWebsockets?: boolean | null; +} + +export interface CodexMcpServerPatchValues { + transport: 'stdio' | 'streamable-http'; + command?: string | null; + args?: string[] | null; + url?: string | null; + enabled?: boolean | null; + required?: boolean | null; + startupTimeoutSec?: number | null; + toolTimeoutSec?: number | null; + enabledTools?: string[] | null; + disabledTools?: string[] | null; +} + +export type CodexConfigPatchInput = + | { + kind: 'top-level'; + expectedMtime?: number; + values: CodexTopLevelSettingsPatch; + } + | { + kind: 'project-trust'; + expectedMtime?: number; + path: string; + trustLevel: string | null; + } + | { + kind: 'feature'; + expectedMtime?: number; + feature: string; + enabled: boolean | null; + } + | { + kind: 'profile'; + expectedMtime?: number; + action: 'set-active' | 'upsert' | 'delete'; + name: string; + values?: CodexProfilePatchValues; + setAsActive?: boolean; + } + | { + kind: 'model-provider'; + expectedMtime?: number; + action: 'upsert' | 'delete'; + name: string; + values?: CodexModelProviderPatchValues; + } + | { + kind: 'mcp-server'; + expectedMtime?: number; + action: 'upsert' | 'delete'; + name: string; + values?: CodexMcpServerPatchValues; + }; + +export interface CodexConfigPatchResult extends CodexRawConfigResponse { + success: true; +} diff --git a/ui/src/hooks/use-codex.ts b/ui/src/hooks/use-codex.ts index ae37799d..4123f272 100644 --- a/ui/src/hooks/use-codex.ts +++ b/ui/src/hooks/use-codex.ts @@ -2,7 +2,12 @@ import { useMemo } from 'react'; import { parse as parseToml } from 'smol-toml'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApiConflictError, withApiBase } from '@/lib/api-client'; -import type { CodexDashboardDiagnostics, CodexRawConfigResponse } from './use-codex-types'; +import type { + CodexConfigPatchInput, + CodexConfigPatchResult, + CodexDashboardDiagnostics, + CodexRawConfigResponse, +} from './use-codex-types'; type CodexRawConfig = CodexRawConfigResponse; @@ -16,6 +21,8 @@ interface SaveCodexRawConfigResponse { mtime: number; } +type PatchCodexConfigResponse = CodexConfigPatchResult; + function parseCodexRawConfigText(rawText: string): { config: Record | null; parseError: string | null; @@ -69,6 +76,21 @@ async function saveCodexRawConfig( return res.json(); } +async function patchCodexConfig(data: CodexConfigPatchInput): Promise { + const res = await fetch(withApiBase('/codex/config/patch'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.status === 409) throw new ApiConflictError('Codex config changed externally'); + + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error || 'Failed to patch Codex config'); + } + return res.json(); +} + export function useCodex() { const queryClient = useQueryClient(); @@ -105,6 +127,14 @@ export function useCodex() { }, }); + const patchConfigMutation = useMutation({ + mutationFn: patchCodexConfig, + onSuccess: (result) => { + queryClient.setQueryData(['codex-raw-config'], result); + queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] }); + }, + }); + return useMemo( () => ({ diagnostics: diagnosticsQuery.data, @@ -120,6 +150,9 @@ export function useCodex() { saveRawConfig: saveRawConfigMutation.mutate, saveRawConfigAsync: saveRawConfigMutation.mutateAsync, isSavingRawConfig: saveRawConfigMutation.isPending, + patchConfig: patchConfigMutation.mutate, + patchConfigAsync: patchConfigMutation.mutateAsync, + isPatchingConfig: patchConfigMutation.isPending, }), [ diagnosticsQuery.data, @@ -133,6 +166,9 @@ export function useCodex() { saveRawConfigMutation.mutate, saveRawConfigMutation.mutateAsync, saveRawConfigMutation.isPending, + patchConfigMutation.mutate, + patchConfigMutation.mutateAsync, + patchConfigMutation.isPending, ] ); } diff --git a/ui/src/lib/codex-config.ts b/ui/src/lib/codex-config.ts new file mode 100644 index 00000000..a2ece0c4 --- /dev/null +++ b/ui/src/lib/codex-config.ts @@ -0,0 +1,225 @@ +export interface CodexTopLevelSettingsView { + model: string | null; + modelReasoningEffort: string | null; + modelProvider: string | null; + approvalPolicy: string | null; + sandboxMode: string | null; + webSearch: string | null; + toolOutputTokenLimit: number | null; + personality: string | null; +} + +export interface CodexProjectTrustEntry { + path: string; + trustLevel: string; +} + +export interface CodexProfileEntry { + name: string; + values: CodexTopLevelSettingsView; +} + +export interface CodexModelProviderEntry { + name: string; + displayName: string | null; + baseUrl: string | null; + envKey: string | null; + wireApi: string | null; + requiresOpenaiAuth: boolean; + supportsWebsockets: boolean; +} + +export interface CodexMcpServerEntry { + name: string; + transport: 'stdio' | 'streamable-http'; + command: string | null; + args: string[]; + url: string | null; + enabled: boolean; + required: boolean; + startupTimeoutSec: number | null; + toolTimeoutSec: number | null; + enabledTools: string[]; + disabledTools: string[]; +} + +export interface CodexFeatureCatalogEntry { + name: string; + label: string; + description: string; +} + +export const KNOWN_CODEX_FEATURES: CodexFeatureCatalogEntry[] = [ + { + name: 'multi_agent', + label: 'Multi-agent', + description: 'Enable subagent collaboration tools.', + }, + { + name: 'unified_exec', + label: 'Unified exec', + description: 'Use the PTY-backed unified exec tool.', + }, + { + name: 'shell_snapshot', + label: 'Shell snapshot', + description: 'Reuse shell environment snapshots.', + }, + { + name: 'apply_patch_freeform', + label: 'Apply patch', + description: 'Enable freeform apply_patch edits.', + }, + { name: 'js_repl', label: 'JS REPL', description: 'Enable the Node-backed JavaScript REPL.' }, + { + name: 'runtime_metrics', + label: 'Runtime metrics', + description: 'Collect Codex runtime metrics.', + }, + { + name: 'prevent_idle_sleep', + label: 'Prevent idle sleep', + description: 'Keep the machine awake while active.', + }, + { name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' }, + { name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' }, + { + name: 'smart_approvals', + label: 'Smart approvals', + description: 'Route eligible approvals through the guardian flow.', + }, +]; + +function asObject(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + : []; +} + +export function readCodexTopLevelSettings( + config: Record | null +): CodexTopLevelSettingsView { + return { + model: asString(config?.model), + modelReasoningEffort: asString(config?.model_reasoning_effort), + modelProvider: asString(config?.model_provider), + approvalPolicy: asString(config?.approval_policy), + sandboxMode: asString(config?.sandbox_mode), + webSearch: asString(config?.web_search), + toolOutputTokenLimit: asNumber(config?.tool_output_token_limit), + personality: asString(config?.personality), + }; +} + +export function readCodexProjectTrust( + config: Record | null +): CodexProjectTrustEntry[] { + const projects = asObject(config?.projects); + if (!projects) return []; + + return Object.entries(projects) + .map(([projectPath, value]) => { + const trustLevel = asString(asObject(value)?.trust_level); + return trustLevel ? { path: projectPath, trustLevel } : null; + }) + .filter((entry): entry is CodexProjectTrustEntry => entry !== null) + .sort((left, right) => left.path.localeCompare(right.path)); +} + +export function readCodexProfiles(config: Record | null): CodexProfileEntry[] { + const profiles = asObject(config?.profiles); + if (!profiles) return []; + + return Object.entries(profiles) + .map(([name, value]) => ({ name, values: readCodexTopLevelSettings(asObject(value)) })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function readCodexModelProviders( + config: Record | null +): CodexModelProviderEntry[] { + const providers = asObject(config?.model_providers); + if (!providers) return []; + + return Object.entries(providers) + .map(([name, value]) => { + const provider = asObject(value); + if (!provider) return null; + return { + name, + displayName: asString(provider.name), + baseUrl: asString(provider.base_url), + envKey: asString(provider.env_key), + wireApi: asString(provider.wire_api), + requiresOpenaiAuth: provider.requires_openai_auth === true, + supportsWebsockets: provider.supports_websockets === true, + }; + }) + .filter((entry): entry is CodexModelProviderEntry => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function readCodexMcpServers(config: Record | null): CodexMcpServerEntry[] { + const servers = asObject(config?.mcp_servers); + if (!servers) return []; + + return Object.entries(servers) + .map(([name, value]) => { + const server = asObject(value); + if (!server) return null; + const transport = asString(server.command) ? 'stdio' : 'streamable-http'; + return { + name, + transport, + command: asString(server.command), + args: asStringArray(server.args), + url: asString(server.url), + enabled: server.enabled !== false, + required: server.required === true, + startupTimeoutSec: asNumber(server.startup_timeout_sec), + toolTimeoutSec: asNumber(server.tool_timeout_sec), + enabledTools: asStringArray(server.enabled_tools), + disabledTools: asStringArray(server.disabled_tools), + }; + }) + .filter((entry): entry is CodexMcpServerEntry => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function readCodexFeatureState( + config: Record | null +): Record { + const features = asObject(config?.features); + const state: Record = {}; + + for (const feature of KNOWN_CODEX_FEATURES) { + const value = features?.[feature.name]; + state[feature.name] = typeof value === 'boolean' ? value : null; + } + + if (features) { + for (const [name, value] of Object.entries(features)) { + if (!(name in state)) { + state[name] = typeof value === 'boolean' ? value : null; + } + } + } + + return state; +} diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx index ef5b8fe6..d7449554 100644 --- a/ui/src/pages/codex.tsx +++ b/ui/src/pages/codex.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { parse as parseToml } from 'smol-toml'; import { toast } from 'sonner'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; @@ -6,7 +6,6 @@ import { AlertTriangle, CheckCircle2, ExternalLink, - FileWarning, Folder, GripVertical, Info, @@ -19,7 +18,13 @@ import { import { useCodex } from '@/hooks/use-codex'; import { isApiConflictError } from '@/lib/api-client'; import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; -import { UsageCommand } from '@/components/cliproxy/provider-editor/usage-command'; +import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card'; +import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card'; +import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card'; +import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card'; +import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card'; +import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card'; +import { QuickCommands } from '@/components/shared'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -33,6 +38,15 @@ import { TableRow, } from '@/components/ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + KNOWN_CODEX_FEATURES, + readCodexFeatureState, + readCodexMcpServers, + readCodexModelProviders, + readCodexProfiles, + readCodexProjectTrust, + readCodexTopLevelSettings, +} from '@/lib/codex-config'; import { cn } from '@/lib/utils'; const DEFAULT_CODEX_DOC_LINKS = [ @@ -157,9 +171,12 @@ export function CodexPage() { refetchDiagnostics, rawConfig, rawConfigLoading, + rawConfigError, refetchRawConfig, saveRawConfigAsync, isSavingRawConfig, + patchConfigAsync, + isPatchingConfig, } = useCodex(); const [rawDraftText, setRawDraftText] = useState(null); @@ -170,6 +187,34 @@ export function CodexPage() { const rawEditorValidation = rawEditorParsed.valid ? { valid: true as const } : { valid: false as const, error: rawEditorParsed.error }; + const controlsConfig = rawConfig?.config ?? null; + const structuredControlsDisabled = + rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null; + const controlsDisabledReason = rawConfigError + ? 'Structured controls unavailable: failed to load the current config.toml.' + : rawConfigDirty + ? rawEditorValidation.valid + ? 'Save or discard raw TOML edits before using structured controls.' + : 'Fix or discard raw TOML edits before using structured controls.' + : rawConfig?.parseError + ? `Structured controls disabled: ${rawConfig.parseError}` + : null; + + const topLevelSettings = useMemo( + () => readCodexTopLevelSettings(controlsConfig), + [controlsConfig] + ); + const projectTrustEntries = useMemo( + () => readCodexProjectTrust(controlsConfig), + [controlsConfig] + ); + const profileEntries = useMemo(() => readCodexProfiles(controlsConfig), [controlsConfig]); + const modelProviderEntries = useMemo( + () => readCodexModelProviders(controlsConfig), + [controlsConfig] + ); + const mcpServerEntries = useMemo(() => readCodexMcpServers(controlsConfig), [controlsConfig]); + const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]); const setRawEditorDraftText = (nextText: string) => { if (nextText === rawBaseText) { @@ -206,6 +251,26 @@ export function CodexPage() { } }; + const runConfigPatch = async ( + patch: Parameters[0], + successMessage: string + ) => { + try { + await patchConfigAsync({ + ...patch, + expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined, + }); + setRawDraftText(null); + toast.success(successMessage); + } catch (error) { + if (isApiConflictError(error)) { + toast.error('config.toml changed externally. Refresh and retry.'); + } else { + toast.error((error as Error).message || 'Failed to update Codex config.'); + } + } + }; + const renderOverview = () => { if (diagnosticsLoading) { return ( @@ -242,7 +307,7 @@ export function CodexPage() {
Overview - Runtime & Routing + Control Center Docs
@@ -424,30 +489,31 @@ export function CodexPage() { - {diagnostics.warnings.length > 0 && ( - - - - - Warnings - - - - {diagnostics.warnings.map((warning) => ( -

- - {warning} -

- ))} -
-
- )} -
- - + - - -
@@ -507,46 +573,146 @@ export function CodexPage() { - - - Quick usage - - - - - - - - + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+
+ + +
- - What this editor affects + + Structured controls boundary

- Edits here affect native Codex sessions first because this is the user-layer{' '} - config.toml. + Guided controls write only the user-layer config.toml. They do + not model the full effective Codex runtime once trusted repo layers and CCS + transient -c overrides are involved.

- CCS-routed Codex launches may override provider-related keys transiently and - inject CCS_CODEX_API_KEY. -

-

- That means the file is not a complete source of truth for every routed Codex - launch you start from CCS. + Structured saves normalize TOML formatting and strip comments. Use the raw + editor on the right when exact layout matters.

+ + entry.name)} + disabled={structuredControlsDisabled} + disabledReason={controlsDisabledReason} + saving={isPatchingConfig} + onSave={(values) => + runConfigPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.') + } + /> + + + runConfigPatch( + { kind: 'project-trust', path: projectPath, trustLevel }, + trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.' + ) + } + /> + + entry.name)} + disabled={structuredControlsDisabled} + disabledReason={controlsDisabledReason} + saving={isPatchingConfig} + onSave={(name, values, setAsActive) => + runConfigPatch( + { kind: 'profile', action: 'upsert', name, values, setAsActive }, + 'Saved profile.' + ) + } + onDelete={(name) => + runConfigPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.') + } + onSetActive={(name) => + runConfigPatch( + { kind: 'profile', action: 'set-active', name }, + 'Set active profile.' + ) + } + /> + + + runConfigPatch( + { kind: 'model-provider', action: 'upsert', name, values }, + 'Saved model provider.' + ) + } + onDelete={(name) => + runConfigPatch( + { kind: 'model-provider', action: 'delete', name }, + 'Deleted model provider.' + ) + } + /> + + + runConfigPatch( + { kind: 'mcp-server', action: 'upsert', name, values }, + 'Saved MCP server.' + ) + } + onDelete={(name) => + runConfigPatch( + { kind: 'mcp-server', action: 'delete', name }, + 'Deleted MCP server.' + ) + } + /> + + + runConfigPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.') + } + />
diff --git a/ui/tests/unit/hooks/use-codex.test.tsx b/ui/tests/unit/hooks/use-codex.test.tsx new file mode 100644 index 00000000..0d3bfb67 --- /dev/null +++ b/ui/tests/unit/hooks/use-codex.test.tsx @@ -0,0 +1,142 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { AllProviders } from '../../setup/test-utils'; +import { useCodex } from '@/hooks/use-codex'; + +function createJsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const diagnosticsResponse = { + binary: { + installed: true, + path: '/tmp/codex', + installDir: '/tmp', + source: 'PATH', + version: 'codex-cli 0.118.0-alpha.3', + overridePath: null, + supportsConfigOverrides: true, + }, + file: { + label: 'Codex user config', + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + isSymlink: false, + isRegularFile: true, + sizeBytes: 64, + mtimeMs: 100, + parseError: null, + readError: null, + }, + workspacePath: '/tmp/workspace', + config: { + model: 'gpt-5.3-codex', + modelReasoningEffort: null, + modelProvider: null, + activeProfile: null, + approvalPolicy: null, + sandboxMode: null, + webSearch: null, + toolOutputTokenLimit: null, + personality: null, + topLevelKeys: ['model'], + profileCount: 0, + profileNames: [], + modelProviderCount: 0, + modelProviders: [], + featureCount: 0, + enabledFeatures: [], + disabledFeatures: [], + trustedProjectCount: 0, + untrustedProjectCount: 0, + projectTrust: [], + mcpServerCount: 0, + mcpServers: [], + }, + supportMatrix: [], + warnings: [], + docsReference: { + providerValues: [], + settingsHierarchy: [], + notes: [], + links: [], + providerDocs: [], + }, +}; + +const initialRawConfigResponse = { + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + mtime: 100, + rawText: 'model = "gpt-5.3-codex"\n', + config: { model: 'gpt-5.3-codex' }, + parseError: null, +}; + +const patchedRawConfigResponse = { + success: true, + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + mtime: 200, + rawText: 'model = "gpt-5.4"\n', + config: { model: 'gpt-5.4' }, + parseError: null, +}; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +describe('useCodex', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('updates cached raw config immediately after a structured patch save', async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url.endsWith('/api/codex/diagnostics')) { + return Promise.resolve(createJsonResponse(diagnosticsResponse)); + } + + if (url.endsWith('/api/codex/config/raw') && !init?.method) { + return Promise.resolve(createJsonResponse(initialRawConfigResponse)); + } + + if (url.endsWith('/api/codex/config/patch')) { + return Promise.resolve(createJsonResponse(patchedRawConfigResponse)); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => useCodex(), { wrapper }); + + await waitFor(() => expect(result.current.rawConfig?.mtime).toBe(100)); + + await act(async () => { + await result.current.patchConfigAsync({ + kind: 'top-level', + values: { model: 'gpt-5.4' }, + expectedMtime: 100, + }); + }); + + await waitFor(() => expect(result.current.rawConfig?.mtime).toBe(200)); + + expect(result.current.rawConfig?.rawText).toBe('model = "gpt-5.4"\n'); + expect(result.current.rawConfig?.config?.model).toBe('gpt-5.4'); + expect( + fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/codex/config/raw')) + ).toHaveLength(1); + }); +}); From ca981847b2ee889b350c78c0e691aec56cd7787d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 10:53:06 -0400 Subject: [PATCH 07/10] docs(codex): document control center behavior - describe the split-view Codex dashboard and guided config surfaces - document raw-editor guardrails, trust path rules, and reset-to-default support - refresh related architecture and docs cross-links --- README.md | 17 ++++++++++++++--- docs/code-standards.md | 2 +- docs/codebase-summary.md | 16 +++++++++------- docs/project-roadmap.md | 2 +- docs/system-architecture/target-adapters.md | 9 ++++++++- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8bd99406..47db2780 100644 --- a/README.md +++ b/README.md @@ -271,10 +271,21 @@ Not supported in v1: Dashboard parity: `ccs config` -> `Compatible` -> `Codex CLI` The dedicated Codex dashboard reads and writes the user layer only: `~/.codex/config.toml` -(or `$CODEX_HOME/config.toml`). It shows binary detection, a user-layer summary, support -matrix guidance, and upstream docs, while warning that transient CCS runtime overrides such as +(or `$CODEX_HOME/config.toml`). It now ships as a split-view control center: + +- left pane: guided controls for top-level runtime defaults, project trust, profiles, + model providers, MCP servers, and supported feature toggles +- right pane: raw `config.toml` editor for unsupported or exact-fidelity edits +- overview/docs tabs: binary detection, user-layer summary, support matrix guidance, and + upstream OpenAI references + +Structured saves intentionally normalize TOML formatting and drop comments. Use the raw editor +when exact layout matters. Structured edits also refresh the raw snapshot immediately. Guided +controls stay disabled while the raw editor has unsaved or invalid TOML, project trust paths must +be absolute or start with `~/`, and supported feature flags can be cleared back to Codex defaults +with `Use default`. CCS also keeps warning that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` can change the effective runtime without persisting -back into that file. +back into the user config file. ### Per-Profile Target Defaults diff --git a/docs/code-standards.md b/docs/code-standards.md index 58746d37..a3a3896b 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -719,5 +719,5 @@ This pattern is used in: ## Related Documentation - [Codebase Summary](./codebase-summary.md) - Full directory structure -- [System Architecture](./system-architecture.md) - Architecture diagrams +- [System Architecture](./system-architecture/index.md) - Architecture diagrams - [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index acc97821..7224d95e 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -252,6 +252,9 @@ src/ - 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. - Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection. +- Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback. +- Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch. +- Follow-up behavior: structured saves refresh the raw snapshot immediately, structured controls stay disabled while raw TOML is dirty or invalid, project trust paths must be absolute or `~/...`, and feature flags can be reset to default. - Supported Codex flows in v1: - `default` - CLIProxy provider `codex` @@ -268,12 +271,11 @@ The targets module provides an extensible interface for dispatching profiles to **Key components:** 1. **TargetAdapter Interface** - Contract that each CLI implementation must fulfill: - - `detectBinary()` - Find CLI binary on system (platform-specific) - - `prepareCredentials()` - Deliver credentials (env vars vs config file writes) - - `buildArgs()` - Construct target-specific argument list - - `buildEnv()` - Construct environment for target CLI - - `exec()` - Spawn target process (cross-platform) - - `supportsProfileType()` - Verify profile compatibility + - binary detection + - credential preparation + - target-specific args/env construction + - process execution + - profile compatibility checks 2. **Target Resolution** - Priority order: - `--target ` flag (CLI argument) @@ -627,7 +629,7 @@ tests/ ## Related Documentation - [Code Standards](./code-standards.md) - Modularization patterns, file size rules -- [System Architecture](./system-architecture.md) - High-level architecture diagrams +- [System Architecture](./system-architecture/index.md) - High-level architecture diagrams - [Project Roadmap](./project-roadmap.md) - Modularization phases and future work - [WebSearch](./websearch.md) - WebSearch feature documentation - [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 3e273407..8e4aa166 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,7 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes -- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route. The page detects the local Codex binary, reads and writes the user-owned `~/.codex/config.toml` layer, surfaces support-matrix/runtime-routing guidance, links to official OpenAI Codex docs, and warns that transient CCS runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. +- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, dirty raw-editor guarding for structured controls, project-trust path validation, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. - **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. - **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts. - **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected. diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 1fa1eb5d..c3de64f0 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -471,10 +471,17 @@ Codex is a real runtime target, but it is intentionally narrower than Claude or ### Codex Dashboard Surface CCS also exposes a dedicated dashboard route at `ccs config` -> `Compatible` -> `Codex CLI`. -That page is intentionally narrower than the Droid dashboard: +That page is intentionally narrower than the Droid dashboard in overall scope, but it is no +longer read-mostly: - reads and writes only the user config layer: `~/.codex/config.toml` or `$CODEX_HOME/config.toml` +- provides guided controls for top-level settings, project trust, profiles, model providers, + MCP servers, and supported feature flags +- keeps a raw `config.toml` editor as the escape hatch for unsupported or fidelity-sensitive edits - shows binary detection, user-layer config summaries, support-matrix guidance, and upstream docs +- normalizes TOML formatting and drops comments on structured saves +- keeps structured controls disabled while raw TOML is dirty or invalid, validates project trust + paths as absolute or `~/...`, and lets feature flags reset back to Codex defaults - warns that transient CCS runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` can change the effective runtime without persisting into the file editor From ebc9acf8e41fccc9ae968d40be75f1f7e4382929 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 11:32:47 -0400 Subject: [PATCH 08/10] fix(codex): harden dashboard config editing --- src/shared/compatible-cli-contracts.ts | 214 ++++++ src/shared/toml-object.ts | 36 + .../services/codex-dashboard-service.ts | 3 +- .../compatible-cli-toml-file-service.ts | 165 +++-- .../services/compatible-cli-types.ts | 229 +----- .../codex-dashboard-service.test.ts | 66 +- .../codex-control-center-tab.tsx | 165 +++++ .../compatible-cli/codex-docs-tab.tsx | 184 +++++ .../compatible-cli/codex-overview-tab.tsx | 315 ++++++++ ui/src/hooks/use-codex-types.ts | 234 +----- ui/src/hooks/use-codex.ts | 28 +- ui/src/pages/codex.tsx | 685 +----------------- 12 files changed, 1182 insertions(+), 1142 deletions(-) create mode 100644 src/shared/compatible-cli-contracts.ts create mode 100644 src/shared/toml-object.ts create mode 100644 ui/src/components/compatible-cli/codex-control-center-tab.tsx create mode 100644 ui/src/components/compatible-cli/codex-docs-tab.tsx create mode 100644 ui/src/components/compatible-cli/codex-overview-tab.tsx diff --git a/src/shared/compatible-cli-contracts.ts b/src/shared/compatible-cli-contracts.ts new file mode 100644 index 00000000..8d9583c5 --- /dev/null +++ b/src/shared/compatible-cli-contracts.ts @@ -0,0 +1,214 @@ +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider' | 'openai' | 'github'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + +export type CodexBinarySource = 'CCS_CODEX_PATH' | 'PATH' | 'missing'; + +export interface CodexBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: CodexBinarySource; + version: string | null; + overridePath: string | null; + supportsConfigOverrides: boolean; +} + +export interface CodexConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface CodexModelProviderDiagnostics { + name: string; + baseUrl: string | null; + envKey: string | null; + wireApi: string | null; + requiresOpenaiAuth: boolean; + supportsWebsockets: boolean; + hasQueryParams: boolean; + hasHttpHeaders: boolean; + usesExperimentalBearerToken: boolean; +} + +export interface CodexFeatureFlagDiagnostics { + name: string; + state: 'enabled' | 'disabled' | 'custom'; +} + +export interface CodexProjectTrustDiagnostics { + path: string; + trustLevel: string; +} + +export interface CodexMcpServerDiagnostics { + name: string; + transport: 'stdio' | 'streamable-http' | 'unknown'; + enabled: boolean; + required: boolean; + startupTimeoutSec: number | null; + toolTimeoutSec: number | null; + enabledToolsCount: number; + disabledToolsCount: number; + usesInlineBearerToken: boolean; +} + +export interface CodexSupportMatrixEntry { + id: string; + label: string; + supported: boolean; + notes: string; +} + +export interface CodexUserConfigDiagnostics { + model: string | null; + modelReasoningEffort: string | null; + modelProvider: string | null; + activeProfile: string | null; + approvalPolicy: string | null; + sandboxMode: string | null; + webSearch: string | null; + toolOutputTokenLimit: number | null; + personality: string | null; + topLevelKeys: string[]; + profileCount: number; + profileNames: string[]; + modelProviderCount: number; + modelProviders: CodexModelProviderDiagnostics[]; + featureCount: number; + enabledFeatures: CodexFeatureFlagDiagnostics[]; + disabledFeatures: CodexFeatureFlagDiagnostics[]; + trustedProjectCount: number; + untrustedProjectCount: number; + projectTrust: CodexProjectTrustDiagnostics[]; + mcpServerCount: number; + mcpServers: CodexMcpServerDiagnostics[]; +} + +export interface CodexDashboardDiagnostics { + binary: CodexBinaryDiagnostics; + file: CodexConfigFileDiagnostics; + workspacePath: string; + config: CodexUserConfigDiagnostics; + supportMatrix: CodexSupportMatrixEntry[]; + warnings: string[]; + docsReference: CompatibleCliDocsReference; +} + +export interface CodexRawConfigResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + config: Record | null; + parseError: string | null; +} + +export interface CodexTopLevelSettingsPatch { + model?: string | null; + modelReasoningEffort?: string | null; + modelProvider?: string | null; + approvalPolicy?: string | null; + sandboxMode?: string | null; + webSearch?: string | null; + toolOutputTokenLimit?: number | null; + personality?: string | null; +} + +export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {} + +export interface CodexModelProviderPatchValues { + displayName?: string | null; + baseUrl?: string | null; + envKey?: string | null; + wireApi?: string | null; + requiresOpenaiAuth?: boolean | null; + supportsWebsockets?: boolean | null; +} + +export interface CodexMcpServerPatchValues { + transport: 'stdio' | 'streamable-http'; + command?: string | null; + args?: string[] | null; + url?: string | null; + enabled?: boolean | null; + required?: boolean | null; + startupTimeoutSec?: number | null; + toolTimeoutSec?: number | null; + enabledTools?: string[] | null; + disabledTools?: string[] | null; +} + +export type CodexConfigPatchInput = + | { + kind: 'top-level'; + expectedMtime?: number; + values: CodexTopLevelSettingsPatch; + } + | { + kind: 'project-trust'; + expectedMtime?: number; + path: string; + trustLevel: string | null; + } + | { + kind: 'feature'; + expectedMtime?: number; + feature: string; + enabled: boolean | null; + } + | { + kind: 'profile'; + expectedMtime?: number; + action: 'set-active' | 'upsert' | 'delete'; + name: string; + values?: CodexProfilePatchValues; + setAsActive?: boolean; + } + | { + kind: 'model-provider'; + expectedMtime?: number; + action: 'upsert' | 'delete'; + name: string; + values?: CodexModelProviderPatchValues; + } + | { + kind: 'mcp-server'; + expectedMtime?: number; + action: 'upsert' | 'delete'; + name: string; + values?: CodexMcpServerPatchValues; + }; + +export interface CodexConfigPatchResult extends CodexRawConfigResponse { + success: true; +} diff --git a/src/shared/toml-object.ts b/src/shared/toml-object.ts new file mode 100644 index 00000000..0e393db2 --- /dev/null +++ b/src/shared/toml-object.ts @@ -0,0 +1,36 @@ +import { parse } from 'smol-toml'; + +export interface SafeTomlObjectParseResult { + config: Record | null; + parseError: string | null; +} + +function isTomlObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseTomlObject(rawText: string): Record { + const trimmed = rawText.trim(); + if (!trimmed) return {}; + + const parsed = parse(rawText); + if (!isTomlObject(parsed)) { + throw new Error('TOML root must be a table.'); + } + + return parsed; +} + +export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult { + try { + return { + config: parseTomlObject(rawText), + parseError: null, + }; + } catch (error) { + return { + config: null, + parseError: (error as Error).message, + }; + } +} diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index ca98b630..3ebabb1f 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -134,8 +134,7 @@ function setEnumStringField( } const normalized = value.trim(); - const currentValue = asString(target[key]); - if (!allowedValues.has(normalized) && normalized !== currentValue) { + if (!allowedValues.has(normalized)) { throw new TomlFileValidationError( `${label} must be one of: ${Array.from(allowedValues).join(', ')}.` ); diff --git a/src/web-server/services/compatible-cli-toml-file-service.ts b/src/web-server/services/compatible-cli-toml-file-service.ts index 312a04c3..95613852 100644 --- a/src/web-server/services/compatible-cli-toml-file-service.ts +++ b/src/web-server/services/compatible-cli-toml-file-service.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import { parse, stringify } from 'smol-toml'; +import { stringify } from 'smol-toml'; +import { parseTomlObject } from '../../shared/toml-object'; export interface TomlFileDiagnostics { label: string; @@ -67,6 +68,111 @@ async function statPath(filePath: string): Promise { } } +async function resolveConflictMtime(filePath: string): Promise { + const stat = await statPath(filePath); + return stat?.mtimeMs ?? Date.now(); +} + +async function acquireWriteLock( + lockPath: string, + targetPath: string, + fileLabel: string +): Promise<() => Promise> { + let handle: Awaited> | null = null; + try { + handle = await fs.open(lockPath, 'wx', 0o600); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EEXIST') { + const existingLock = await statPath(lockPath); + if (existingLock?.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.lock is a symlink.`); + } + if (existingLock && !existingLock.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.lock is not a regular file.`); + } + throw new TomlFileConflictError( + 'File is currently being written by another request. Refresh and retry.', + await resolveConflictMtime(targetPath) + ); + } + throw error; + } + + return async () => { + if (!handle) return; + try { + await handle.close(); + } finally { + try { + await fs.unlink(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + }; +} + +function ensureWritableTarget( + targetStat: import('fs').Stats | null, + fileLabel: string +): import('fs').Stats | null { + if (!targetStat) return null; + if (targetStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); + } + if (!targetStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`); + } + return targetStat; +} + +function assertExpectedMtime( + targetStat: import('fs').Stats | null, + expectedMtime: number | undefined +): void { + if (!targetStat) { + if (expectedMtime !== undefined) { + throw new TomlFileConflictError('File modified externally.', Date.now()); + } + return; + } + + if (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) { + throw new TomlFileConflictError( + 'File metadata not loaded. Refresh and retry.', + targetStat.mtimeMs + ); + } + if (targetStat.mtimeMs !== expectedMtime) { + throw new TomlFileConflictError('File modified externally.', targetStat.mtimeMs); + } +} + +async function verifyTargetUnchanged( + targetPath: string, + initialTargetStat: import('fs').Stats | null, + fileLabel: string +): Promise { + const currentTargetStat = ensureWritableTarget(await statPath(targetPath), fileLabel); + + if (!initialTargetStat) { + if (currentTargetStat) { + throw new TomlFileConflictError('File modified externally.', currentTargetStat.mtimeMs); + } + return; + } + + if (!currentTargetStat || currentTargetStat.mtimeMs !== initialTargetStat.mtimeMs) { + throw new TomlFileConflictError( + 'File modified externally.', + currentTargetStat?.mtimeMs ?? Date.now() + ); + } +} + export function parseTomlObjectText( rawText: string, fieldName = 'rawText' @@ -75,21 +181,15 @@ export function parseTomlObjectText( throw new TomlFileValidationError(`${fieldName} must be a string.`); } - const trimmed = rawText.trim(); - if (!trimmed) return {}; - - let parsed: unknown; try { - parsed = parse(rawText); + return parseTomlObject(rawText); } catch (error) { - throw new TomlFileValidationError(`Invalid TOML in ${fieldName}: ${(error as Error).message}`); + const message = (error as Error).message; + if (message === 'TOML root must be a table.') { + throw new TomlFileValidationError(`${fieldName} TOML root must be a table.`); + } + throw new TomlFileValidationError(`Invalid TOML in ${fieldName}: ${message}`); } - - if (!isObject(parsed)) { - throw new TomlFileValidationError(`${fieldName} TOML root must be a table.`); - } - - return parsed; } export function stringifyTomlObject(config: Record): string { @@ -170,45 +270,21 @@ export async function writeTomlFileAtomic(input: WriteTomlFileInput): Promise 1000) { - throw new TomlFileConflictError('File modified externally.', targetStat.mtimeMs); - } - } + const releaseLock = await acquireWriteLock(lockPath, targetPath, fileLabel); let wroteTemp = false; try { - const existingTempStat = await statPath(tempPath); - if (existingTempStat) { - if (existingTempStat.isSymbolicLink()) { - throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); - } - if (!existingTempStat.isFile()) { - throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); - } - } + const targetStat = ensureWritableTarget(await statPath(targetPath), fileLabel); + assertExpectedMtime(targetStat, input.expectedMtime); - await fs.writeFile(tempPath, input.rawText, { mode: fileMode }); + await fs.writeFile(tempPath, input.rawText, { mode: fileMode, flag: 'wx' }); wroteTemp = true; const tempStat = await fs.lstat(tempPath); @@ -219,6 +295,7 @@ export async function writeTomlFileAtomic(input: WriteTomlFileInput): Promise | null; parseError: string | null; } - -export type CodexBinarySource = 'CCS_CODEX_PATH' | 'PATH' | 'missing'; - -export interface CodexBinaryDiagnostics { - installed: boolean; - path: string | null; - installDir: string | null; - source: CodexBinarySource; - version: string | null; - overridePath: string | null; - supportsConfigOverrides: boolean; -} - -export type CodexConfigFileDiagnostics = DroidConfigFileDiagnostics; - -export interface CodexModelProviderDiagnostics { - name: string; - baseUrl: string | null; - envKey: string | null; - wireApi: string | null; - requiresOpenaiAuth: boolean; - supportsWebsockets: boolean; - hasQueryParams: boolean; - hasHttpHeaders: boolean; - usesExperimentalBearerToken: boolean; -} - -export interface CodexFeatureFlagDiagnostics { - name: string; - state: 'enabled' | 'disabled' | 'custom'; -} - -export interface CodexProjectTrustDiagnostics { - path: string; - trustLevel: string; -} - -export interface CodexMcpServerDiagnostics { - name: string; - transport: 'stdio' | 'streamable-http' | 'unknown'; - enabled: boolean; - required: boolean; - startupTimeoutSec: number | null; - toolTimeoutSec: number | null; - enabledToolsCount: number; - disabledToolsCount: number; - usesInlineBearerToken: boolean; -} - -export interface CodexSupportMatrixEntry { - id: string; - label: string; - supported: boolean; - notes: string; -} - -export interface CodexUserConfigDiagnostics { - model: string | null; - modelReasoningEffort: string | null; - modelProvider: string | null; - activeProfile: string | null; - approvalPolicy: string | null; - sandboxMode: string | null; - webSearch: string | null; - toolOutputTokenLimit: number | null; - personality: string | null; - topLevelKeys: string[]; - profileCount: number; - profileNames: string[]; - modelProviderCount: number; - modelProviders: CodexModelProviderDiagnostics[]; - featureCount: number; - enabledFeatures: CodexFeatureFlagDiagnostics[]; - disabledFeatures: CodexFeatureFlagDiagnostics[]; - trustedProjectCount: number; - untrustedProjectCount: number; - projectTrust: CodexProjectTrustDiagnostics[]; - mcpServerCount: number; - mcpServers: CodexMcpServerDiagnostics[]; -} - -export interface CodexDashboardDiagnostics { - binary: CodexBinaryDiagnostics; - file: CodexConfigFileDiagnostics; - workspacePath: string; - config: CodexUserConfigDiagnostics; - supportMatrix: CodexSupportMatrixEntry[]; - warnings: string[]; - docsReference: CompatibleCliDocsReference; -} - -export interface CodexRawConfigResponse { - path: string; - resolvedPath: string; - exists: boolean; - mtime: number; - rawText: string; - config: Record | null; - parseError: string | null; -} - -export interface CodexTopLevelSettingsPatch { - model?: string | null; - modelReasoningEffort?: string | null; - modelProvider?: string | null; - approvalPolicy?: string | null; - sandboxMode?: string | null; - webSearch?: string | null; - toolOutputTokenLimit?: number | null; - personality?: string | null; -} - -export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {} - -export interface CodexModelProviderPatchValues { - displayName?: string | null; - baseUrl?: string | null; - envKey?: string | null; - wireApi?: string | null; - requiresOpenaiAuth?: boolean | null; - supportsWebsockets?: boolean | null; -} - -export interface CodexMcpServerPatchValues { - transport: 'stdio' | 'streamable-http'; - command?: string | null; - args?: string[] | null; - url?: string | null; - enabled?: boolean | null; - required?: boolean | null; - startupTimeoutSec?: number | null; - toolTimeoutSec?: number | null; - enabledTools?: string[] | null; - disabledTools?: string[] | null; -} - -export type CodexConfigPatchInput = - | { - kind: 'top-level'; - expectedMtime?: number; - values: CodexTopLevelSettingsPatch; - } - | { - kind: 'project-trust'; - expectedMtime?: number; - path: string; - trustLevel: string | null; - } - | { - kind: 'feature'; - expectedMtime?: number; - feature: string; - enabled: boolean | null; - } - | { - kind: 'profile'; - expectedMtime?: number; - action: 'set-active' | 'upsert' | 'delete'; - name: string; - values?: CodexProfilePatchValues; - setAsActive?: boolean; - } - | { - kind: 'model-provider'; - expectedMtime?: number; - action: 'upsert' | 'delete'; - name: string; - values?: CodexModelProviderPatchValues; - } - | { - kind: 'mcp-server'; - expectedMtime?: number; - action: 'upsert' | 'delete'; - name: string; - values?: CodexMcpServerPatchValues; - }; - -export interface CodexConfigPatchResult extends CodexRawConfigResponse { - success: true; -} diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index 77e837c8..ac09c04b 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -21,7 +21,8 @@ const codexHome = path.join(testRoot, '.codex-home'); const codexStubPath = path.join(testRoot, 'codex'); function writeCodexStub(options?: { helpText?: string; version?: string }) { - const helpText = options?.helpText ?? ' -c, --config \n -p, --profile \n'; + const helpText = + options?.helpText ?? ' -c, --config \n -p, --profile \n'; const version = options?.version ?? 'codex-cli 0.118.0-alpha.3'; fs.writeFileSync( @@ -264,6 +265,20 @@ bearer_token = "secret" ).rejects.toThrow(CodexRawConfigConflictError); }); + it('rejects writes when expectedMtime differs by even 1ms', async () => { + const configPath = path.join(codexHome, 'config.toml'); + fs.writeFileSync(configPath, 'model = "gpt-5.4"\n'); + + const current = await getCodexRawConfig(); + + await expect( + saveCodexRawConfig({ + rawText: 'model = "gpt-5.4"\nprofile = "work"\n', + expectedMtime: current.mtime + 1, + }) + ).rejects.toThrow(CodexRawConfigConflictError); + }); + it('patches top-level settings and project trust through structured controls', async () => { const result = await patchCodexConfig({ kind: 'top-level', @@ -369,6 +384,39 @@ bearer_token = "secret" expect(profileResult.config?.profile).toBe('deep-review'); }); + it('patches streamable-http mcp servers through structured controls', async () => { + const result = await patchCodexConfig({ + kind: 'mcp-server', + action: 'upsert', + name: 'remote', + values: { + transport: 'streamable-http', + url: 'https://example.test/mcp', + enabled: true, + required: true, + toolTimeoutSec: 45, + enabledTools: ['browser_snapshot'], + disabledTools: ['slow_tool'], + }, + }); + + expect(result.rawText).toContain('[mcp_servers.remote]'); + expect(result.rawText).toContain('url = "https://example.test/mcp"'); + expect(result.rawText).toContain('required = true'); + + const diagnostics = await getCodexDashboardDiagnostics(); + expect(diagnostics.config.mcpServers).toEqual([ + expect.objectContaining({ + name: 'remote', + transport: 'streamable-http', + required: true, + toolTimeoutSec: 45, + enabledToolsCount: 1, + disabledToolsCount: 1, + }), + ]); + }); + it('rejects structured patches when config.toml is invalid', async () => { fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n'); @@ -431,4 +479,20 @@ bearer_token = "secret" }) ).rejects.toThrow(CodexRawConfigValidationError); }); + + it('rejects invalid enum values even when they already exist in config.toml', async () => { + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + 'model = "gpt-5.4"\napproval_policy = "legacy"\n' + ); + + await expect( + patchCodexConfig({ + kind: 'top-level', + values: { + approvalPolicy: 'legacy' as unknown as 'on-request' | 'never' | 'untrusted' | null, + }, + }) + ).rejects.toThrow(CodexRawConfigValidationError); + }); }); diff --git a/ui/src/components/compatible-cli/codex-control-center-tab.tsx b/ui/src/components/compatible-cli/codex-control-center-tab.tsx new file mode 100644 index 00000000..9b701521 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-control-center-tab.tsx @@ -0,0 +1,165 @@ +import { Route } from 'lucide-react'; +import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card'; +import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card'; +import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card'; +import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card'; +import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card'; +import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import type { + CodexConfigPatchInput, + CodexProfilePatchValues, + CodexTopLevelSettingsPatch, +} from '@/hooks/use-codex-types'; +import type { + CodexFeatureCatalogEntry, + CodexMcpServerEntry, + CodexModelProviderEntry, + CodexProfileEntry, + CodexProjectTrustEntry, + CodexTopLevelSettingsView, +} from '@/lib/codex-config'; + +interface CodexControlCenterTabProps { + workspacePath: string; + activeProfile: string | null; + topLevelSettings: CodexTopLevelSettingsView; + projectTrustEntries: CodexProjectTrustEntry[]; + profileEntries: CodexProfileEntry[]; + modelProviderEntries: CodexModelProviderEntry[]; + mcpServerEntries: CodexMcpServerEntry[]; + featureCatalog: CodexFeatureCatalogEntry[]; + featureState: Record; + disabled: boolean; + disabledReason: string | null; + saving: boolean; + onPatch: (patch: CodexConfigPatchInput, successMessage: string) => Promise; +} + +export function CodexControlCenterTab({ + workspacePath, + activeProfile, + topLevelSettings, + projectTrustEntries, + profileEntries, + modelProviderEntries, + mcpServerEntries, + featureCatalog, + featureState, + disabled, + disabledReason, + saving, + onPatch, +}: CodexControlCenterTabProps) { + return ( + +
+ + + + + Structured controls boundary + + + +

+ Guided controls write only the user-layer config.toml. They do not model + the full effective Codex runtime once trusted repo layers and CCS transient{' '} + -c overrides are involved. +

+

+ Structured saves normalize TOML formatting and strip comments. Use the raw editor on + the right when exact layout matters. +

+
+
+ + entry.name)} + disabled={disabled} + disabledReason={disabledReason} + saving={saving} + onSave={(values: CodexTopLevelSettingsPatch) => + onPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.') + } + /> + + + onPatch( + { kind: 'project-trust', path: projectPath, trustLevel }, + trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.' + ) + } + /> + + entry.name)} + disabled={disabled} + disabledReason={disabledReason} + saving={saving} + onSave={(name, values: CodexProfilePatchValues, setAsActive) => + onPatch( + { kind: 'profile', action: 'upsert', name, values, setAsActive }, + 'Saved profile.' + ) + } + onDelete={(name) => + onPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.') + } + onSetActive={(name) => + onPatch({ kind: 'profile', action: 'set-active', name }, 'Set active profile.') + } + /> + + + onPatch( + { kind: 'model-provider', action: 'upsert', name, values }, + 'Saved model provider.' + ) + } + onDelete={(name) => + onPatch({ kind: 'model-provider', action: 'delete', name }, 'Deleted model provider.') + } + /> + + + onPatch({ kind: 'mcp-server', action: 'upsert', name, values }, 'Saved MCP server.') + } + onDelete={(name) => + onPatch({ kind: 'mcp-server', action: 'delete', name }, 'Deleted MCP server.') + } + /> + + + onPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.') + } + /> +
+
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-docs-tab.tsx b/ui/src/components/compatible-cli/codex-docs-tab.tsx new file mode 100644 index 00000000..facb4277 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-docs-tab.tsx @@ -0,0 +1,184 @@ +import { type ReactNode } from 'react'; +import { ExternalLink, ShieldCheck } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import type { + CompatibleCliProviderDocLink, + CodexDashboardDiagnostics, +} from '@/hooks/use-codex-types'; + +const DEFAULT_CODEX_DOC_LINKS = [ + { + id: 'codex-config-basic', + label: 'Codex Config Basics', + url: 'https://developers.openai.com/codex/config-basic', + description: 'Official user-layer setup, config location, and baseline configuration behavior.', + }, + { + id: 'codex-config-advanced', + label: 'Codex Config Advanced', + url: 'https://developers.openai.com/codex/config-advanced', + description: 'Layering, trust, profiles, and advanced config behavior.', + }, + { + id: 'codex-config-reference', + label: 'Codex Config Reference', + url: 'https://developers.openai.com/codex/config-reference', + description: 'Canonical upstream config surface for providers, MCP, features, and trust.', + }, + { + id: 'codex-releases', + label: 'Codex GitHub Releases', + url: 'https://github.com/openai/codex/releases', + description: 'Track upstream release notes and fast-moving CLI changes.', + }, +]; + +const DEFAULT_PROVIDER_DOCS: CompatibleCliProviderDocLink[] = [ + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, +]; + +function renderTextWithLinks(text: string): ReactNode[] { + const urlPattern = /https?:\/\/[^\s)]+/g; + const nodes: ReactNode[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(text)) !== null) { + const [url] = match; + const index = match.index; + + if (index > cursor) { + nodes.push(text.slice(cursor, index)); + } + + nodes.push( + + {url} + + ); + cursor = index + url.length; + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +interface CodexDocsTabProps { + diagnostics: CodexDashboardDiagnostics; +} + +export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) { + const docsReference = diagnostics.docsReference ?? { + notes: [], + links: [], + providerDocs: [], + providerValues: [], + settingsHierarchy: [], + }; + const docsLinks = docsReference.links.length > 0 ? docsReference.links : DEFAULT_CODEX_DOC_LINKS; + const providerDocs = + docsReference.providerDocs.length > 0 ? docsReference.providerDocs : DEFAULT_PROVIDER_DOCS; + + return ( + +
+ + + + + Upstream notes + + + + {docsReference.notes.map((note, index) => ( +

+ - {renderTextWithLinks(note)} +

+ ))} + +
+

Codex docs

+ +
+ +
+

+ Provider / bridge reference +

+ +
+ {docsReference.providerValues.length > 0 && ( + <> + +

+ Provider values: {docsReference.providerValues.join(', ')} +

+ + )} + {docsReference.settingsHierarchy.length > 0 && ( +

+ Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')} +

+ )} +
+
+
+
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-overview-tab.tsx b/ui/src/components/compatible-cli/codex-overview-tab.tsx new file mode 100644 index 00000000..9a2f34ee --- /dev/null +++ b/ui/src/components/compatible-cli/codex-overview-tab.tsx @@ -0,0 +1,315 @@ +import { + AlertTriangle, + CheckCircle2, + Folder, + Info, + Route, + ShieldCheck, + TerminalSquare, + XCircle, +} from 'lucide-react'; +import { QuickCommands } from '@/components/shared'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { CodexDashboardDiagnostics } from '@/hooks/use-codex-types'; +import { cn } from '@/lib/utils'; + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return 'N/A'; + return new Date(value).toLocaleString(); +} + +function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(2)} MB`; +} + +function DetailRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +interface CodexOverviewTabProps { + diagnostics: CodexDashboardDiagnostics; +} + +export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) { + return ( + +
+ + + + + How Codex works in CCS + + + +

Codex is a first-class runtime target in CCS, but it stays runtime-only in v1.

+

+ Saved default targets for API profiles and variants still remain on Claude or Droid. +

+

+ CCS-backed Codex launches can apply transient -c overrides and inject + CCS_CODEX_API_KEY, so effective runtime values may not match this file + exactly. +

+
+
+ + + + + + Runtime install + + + +
+ Status + + {diagnostics.binary.installed ? 'Detected' : 'Not found'} + +
+ + + + + +
+ --config override support + + {diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'} + +
+
+
+ + + + + + Config file + + + +
+
+ User config + {diagnostics.file.exists ? ( + + ) : ( + + )} +
+ + + + + {diagnostics.file.parseError && ( +

+ TOML warning: {diagnostics.file.parseError} +

+ )} + {diagnostics.file.readError && ( +

+ Read warning: {diagnostics.file.readError} +

+ )} +
+
+
+ + + + + + Current user-layer summary + + + + + + + + + + +
+ + providers: {diagnostics.config.modelProviderCount} + + + profiles: {diagnostics.config.profileCount} + + + enabled features: {diagnostics.config.enabledFeatures.length} + + + MCP servers: {diagnostics.config.mcpServerCount} + +
+ {diagnostics.config.topLevelKeys.length > 0 && ( +
+

+ User-layer keys present +

+
+ {diagnostics.config.topLevelKeys.map((key) => ( + + {key} + + ))} +
+
+ )} +
+
+ + + + + + + + Runtime vs provider + + + +
+

Native Codex runtime

+

+ Use ccs-codex, ccsx, or --target codex. CCS + launches the local Codex CLI and depends on native Codex capabilities such as{' '} + --config overrides. +

+
+
+

Codex provider / bridge

+

+ CCS can route provider credentials transiently through CLIProxy. That is not the + same as editing local config.toml, and some routed values may never + persist here. +

+
+
+
+ + + + Supported flows + + + + + + Flow + Status + Notes + + + + {diagnostics.supportMatrix.map((entry) => ( + + {entry.label} + + + {entry.supported ? 'Yes' : 'No'} + + + {entry.notes} + + ))} + +
+
+
+ + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+ ); +} diff --git a/ui/src/hooks/use-codex-types.ts b/ui/src/hooks/use-codex-types.ts index af554937..9bfeab27 100644 --- a/ui/src/hooks/use-codex-types.ts +++ b/ui/src/hooks/use-codex-types.ts @@ -1,212 +1,22 @@ -export interface CompatibleCliDocLink { - id: string; - label: string; - url: string; - category: 'overview' | 'configuration' | 'byok' | 'reference'; - source: 'factory' | 'provider' | 'openai' | 'github'; - description: string; -} - -export interface CompatibleCliProviderDocLink { - provider: string; - label: string; - apiFormat: string; - url: string; -} - -export interface CompatibleCliDocsReference { - providerValues: string[]; - settingsHierarchy: string[]; - notes: string[]; - links: CompatibleCliDocLink[]; - providerDocs: CompatibleCliProviderDocLink[]; -} - -export interface CodexBinaryDiagnostics { - installed: boolean; - path: string | null; - installDir: string | null; - source: 'CCS_CODEX_PATH' | 'PATH' | 'missing'; - version: string | null; - overridePath: string | null; - supportsConfigOverrides: boolean; -} - -export interface CodexConfigFileDiagnostics { - label: string; - path: string; - resolvedPath: string; - exists: boolean; - isSymlink: boolean; - isRegularFile: boolean; - sizeBytes: number | null; - mtimeMs: number | null; - parseError: string | null; - readError: string | null; -} - -export interface CodexModelProviderDiagnostics { - name: string; - baseUrl: string | null; - envKey: string | null; - wireApi: string | null; - requiresOpenaiAuth: boolean; - supportsWebsockets: boolean; - hasQueryParams: boolean; - hasHttpHeaders: boolean; - usesExperimentalBearerToken: boolean; -} - -export interface CodexFeatureFlagDiagnostics { - name: string; - state: 'enabled' | 'disabled' | 'custom'; -} - -export interface CodexProjectTrustDiagnostics { - path: string; - trustLevel: string; -} - -export interface CodexMcpServerDiagnostics { - name: string; - transport: 'stdio' | 'streamable-http' | 'unknown'; - enabled: boolean; - required: boolean; - startupTimeoutSec: number | null; - toolTimeoutSec: number | null; - enabledToolsCount: number; - disabledToolsCount: number; - usesInlineBearerToken: boolean; -} - -export interface CodexSupportMatrixEntry { - id: string; - label: string; - supported: boolean; - notes: string; -} - -export interface CodexUserConfigDiagnostics { - model: string | null; - modelReasoningEffort: string | null; - modelProvider: string | null; - activeProfile: string | null; - approvalPolicy: string | null; - sandboxMode: string | null; - webSearch: string | null; - toolOutputTokenLimit: number | null; - personality: string | null; - topLevelKeys: string[]; - profileCount: number; - profileNames: string[]; - modelProviderCount: number; - modelProviders: CodexModelProviderDiagnostics[]; - featureCount: number; - enabledFeatures: CodexFeatureFlagDiagnostics[]; - disabledFeatures: CodexFeatureFlagDiagnostics[]; - trustedProjectCount: number; - untrustedProjectCount: number; - projectTrust: CodexProjectTrustDiagnostics[]; - mcpServerCount: number; - mcpServers: CodexMcpServerDiagnostics[]; -} - -export interface CodexDashboardDiagnostics { - binary: CodexBinaryDiagnostics; - file: CodexConfigFileDiagnostics; - workspacePath: string; - config: CodexUserConfigDiagnostics; - supportMatrix: CodexSupportMatrixEntry[]; - warnings: string[]; - docsReference: CompatibleCliDocsReference; -} - -export interface CodexRawConfigResponse { - path: string; - resolvedPath: string; - exists: boolean; - mtime: number; - rawText: string; - config: Record | null; - parseError: string | null; -} - -export interface CodexTopLevelSettingsPatch { - model?: string | null; - modelReasoningEffort?: string | null; - modelProvider?: string | null; - approvalPolicy?: string | null; - sandboxMode?: string | null; - webSearch?: string | null; - toolOutputTokenLimit?: number | null; - personality?: string | null; -} - -export type CodexProfilePatchValues = CodexTopLevelSettingsPatch; - -export interface CodexModelProviderPatchValues { - displayName?: string | null; - baseUrl?: string | null; - envKey?: string | null; - wireApi?: string | null; - requiresOpenaiAuth?: boolean | null; - supportsWebsockets?: boolean | null; -} - -export interface CodexMcpServerPatchValues { - transport: 'stdio' | 'streamable-http'; - command?: string | null; - args?: string[] | null; - url?: string | null; - enabled?: boolean | null; - required?: boolean | null; - startupTimeoutSec?: number | null; - toolTimeoutSec?: number | null; - enabledTools?: string[] | null; - disabledTools?: string[] | null; -} - -export type CodexConfigPatchInput = - | { - kind: 'top-level'; - expectedMtime?: number; - values: CodexTopLevelSettingsPatch; - } - | { - kind: 'project-trust'; - expectedMtime?: number; - path: string; - trustLevel: string | null; - } - | { - kind: 'feature'; - expectedMtime?: number; - feature: string; - enabled: boolean | null; - } - | { - kind: 'profile'; - expectedMtime?: number; - action: 'set-active' | 'upsert' | 'delete'; - name: string; - values?: CodexProfilePatchValues; - setAsActive?: boolean; - } - | { - kind: 'model-provider'; - expectedMtime?: number; - action: 'upsert' | 'delete'; - name: string; - values?: CodexModelProviderPatchValues; - } - | { - kind: 'mcp-server'; - expectedMtime?: number; - action: 'upsert' | 'delete'; - name: string; - values?: CodexMcpServerPatchValues; - }; - -export interface CodexConfigPatchResult extends CodexRawConfigResponse { - success: true; -} +export type { + CompatibleCliDocLink, + CompatibleCliDocsReference, + CompatibleCliProviderDocLink, + CodexBinaryDiagnostics, + CodexBinarySource, + CodexConfigFileDiagnostics, + CodexConfigPatchInput, + CodexConfigPatchResult, + CodexDashboardDiagnostics, + CodexFeatureFlagDiagnostics, + CodexMcpServerDiagnostics, + CodexMcpServerPatchValues, + CodexModelProviderDiagnostics, + CodexModelProviderPatchValues, + CodexProfilePatchValues, + CodexProjectTrustDiagnostics, + CodexRawConfigResponse, + CodexSupportMatrixEntry, + CodexTopLevelSettingsPatch, + CodexUserConfigDiagnostics, +} from '@shared/compatible-cli-contracts'; diff --git a/ui/src/hooks/use-codex.ts b/ui/src/hooks/use-codex.ts index 4123f272..2291ea1d 100644 --- a/ui/src/hooks/use-codex.ts +++ b/ui/src/hooks/use-codex.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; -import { parse as parseToml } from 'smol-toml'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApiConflictError, withApiBase } from '@/lib/api-client'; +import { safeParseTomlObject } from '@shared/toml-object'; import type { CodexConfigPatchInput, CodexConfigPatchResult, @@ -23,30 +23,6 @@ interface SaveCodexRawConfigResponse { type PatchCodexConfigResponse = CodexConfigPatchResult; -function parseCodexRawConfigText(rawText: string): { - config: Record | null; - parseError: string | null; -} { - try { - const parsed = rawText.trim() ? parseToml(rawText) : {}; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { - config: null, - parseError: 'TOML root must be a table.', - }; - } - return { - config: parsed as Record, - parseError: null, - }; - } catch (error) { - return { - config: null, - parseError: (error as Error).message, - }; - } -} - async function fetchCodexDiagnostics(): Promise { const res = await fetch(withApiBase('/codex/diagnostics')); if (!res.ok) throw new Error('Failed to fetch Codex diagnostics'); @@ -111,7 +87,7 @@ export function useCodex() { queryClient.setQueryData(['codex-raw-config'], (current) => { const path = current?.path ?? '$CODEX_HOME/config.toml'; const resolvedPath = current?.resolvedPath ?? path; - const parsed = parseCodexRawConfigText(variables.rawText); + const parsed = safeParseTomlObject(variables.rawText); return { path, diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx index d7449554..556c3dab 100644 --- a/ui/src/pages/codex.tsx +++ b/ui/src/pages/codex.tsx @@ -1,42 +1,13 @@ -import { type ReactNode, useMemo, useState } from 'react'; -import { parse as parseToml } from 'smol-toml'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import { - AlertTriangle, - CheckCircle2, - ExternalLink, - Folder, - GripVertical, - Info, - Loader2, - Route, - ShieldCheck, - TerminalSquare, - XCircle, -} from 'lucide-react'; +import { GripVertical, Loader2 } from 'lucide-react'; +import { CodexControlCenterTab } from '@/components/compatible-cli/codex-control-center-tab'; +import { CodexDocsTab } from '@/components/compatible-cli/codex-docs-tab'; import { useCodex } from '@/hooks/use-codex'; import { isApiConflictError } from '@/lib/api-client'; +import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab'; import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; -import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card'; -import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card'; -import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card'; -import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card'; -import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card'; -import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card'; -import { QuickCommands } from '@/components/shared'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { KNOWN_CODEX_FEATURES, @@ -47,121 +18,7 @@ import { readCodexProjectTrust, readCodexTopLevelSettings, } from '@/lib/codex-config'; -import { cn } from '@/lib/utils'; - -const DEFAULT_CODEX_DOC_LINKS = [ - { - id: 'codex-config-basic', - label: 'Codex Config Basics', - url: 'https://developers.openai.com/codex/config-basic', - description: 'Official user-layer setup, config location, and baseline configuration behavior.', - }, - { - id: 'codex-config-advanced', - label: 'Codex Config Advanced', - url: 'https://developers.openai.com/codex/config-advanced', - description: 'Layering, trust, profiles, and advanced config behavior.', - }, - { - id: 'codex-config-reference', - label: 'Codex Config Reference', - url: 'https://developers.openai.com/codex/config-reference', - description: 'Canonical upstream config surface for providers, MCP, features, and trust.', - }, - { - id: 'codex-releases', - label: 'Codex GitHub Releases', - url: 'https://github.com/openai/codex/releases', - description: 'Track upstream release notes and fast-moving CLI changes.', - }, -]; - -const DEFAULT_PROVIDER_DOCS = [ - { - provider: 'openai', - label: 'OpenAI Responses API', - apiFormat: 'Responses API', - url: 'https://platform.openai.com/docs/api-reference/responses', - }, -]; - -function renderTextWithLinks(text: string): ReactNode[] { - const urlPattern = /https?:\/\/[^\s)]+/g; - const nodes: ReactNode[] = []; - let cursor = 0; - let match: RegExpExecArray | null; - - while ((match = urlPattern.exec(text)) !== null) { - const [url] = match; - const index = match.index; - - if (index > cursor) { - nodes.push(text.slice(cursor, index)); - } - - nodes.push( - - {url} - - ); - cursor = index + url.length; - } - - if (cursor < text.length) { - nodes.push(text.slice(cursor)); - } - - return nodes.length > 0 ? nodes : [text]; -} - -function formatTimestamp(value: number | null | undefined): string { - if (!value || !Number.isFinite(value)) return 'N/A'; - return new Date(value).toLocaleString(); -} - -function formatBytes(value: number | null | undefined): string { - if (!value || value <= 0) return '0 B'; - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; - return `${(value / (1024 * 1024)).toFixed(2)} MB`; -} - -function parseTomlObjectText( - text: string -): { valid: true; value: Record } | { valid: false; error: string } { - try { - const parsed = text.trim() ? parseToml(text) : {}; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { valid: false, error: 'TOML root must be a table.' }; - } - return { valid: true, value: parsed as Record }; - } catch (error) { - return { valid: false, error: (error as Error).message }; - } -} - -function DetailRow({ - label, - value, - mono = false, -}: { - label: string; - value: string; - mono?: boolean; -}) { - return ( -
- {label} - {value} -
- ); -} +import { safeParseTomlObject } from '@shared/toml-object'; export function CodexPage() { const { @@ -183,10 +40,10 @@ export function CodexPage() { const rawBaseText = rawConfig?.rawText ?? ''; const rawEditorText = rawDraftText ?? rawBaseText; const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; - const rawEditorParsed = parseTomlObjectText(rawEditorText); - const rawEditorValidation = rawEditorParsed.valid - ? { valid: true as const } - : { valid: false as const, error: rawEditorParsed.error }; + const rawEditorParsed = safeParseTomlObject(rawEditorText); + const rawEditorValidation = rawEditorParsed.parseError + ? { valid: false as const, error: rawEditorParsed.parseError } + : { valid: true as const }; const controlsConfig = rawConfig?.config ?? null; const structuredControlsDisabled = rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null; @@ -271,7 +128,9 @@ export function CodexPage() { } }; - const renderOverview = () => { + const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden'; + + const renderSidebar = () => { if (diagnosticsLoading) { return (
@@ -289,19 +148,6 @@ export function CodexPage() { ); } - const docsReference = diagnostics.docsReference ?? { - notes: [], - links: [], - providerDocs: [], - providerValues: [], - settingsHierarchy: [], - }; - const docsLinks = - docsReference.links.length > 0 ? docsReference.links : DEFAULT_CODEX_DOC_LINKS; - const providerDocs = - docsReference.providerDocs.length > 0 ? docsReference.providerDocs : DEFAULT_PROVIDER_DOCS; - const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden'; - return (
@@ -314,498 +160,29 @@ export function CodexPage() {
- -
- - - - - How Codex works in CCS - - - -

- Codex is a first-class runtime target in CCS, but it stays runtime-only in v1. -

-

- Saved default targets for API profiles and variants still remain on Claude or - Droid. -

-

- CCS-backed Codex launches can apply transient -c overrides and - inject CCS_CODEX_API_KEY, so effective runtime values may not - match this file exactly. -

-
-
- - - - - - Runtime install - - - -
- Status - - {diagnostics.binary.installed ? 'Detected' : 'Not found'} - -
- - - - - -
- - --config override support - - - {diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'} - -
-
-
- - - - - - Config file - - - -
-
- User config - {diagnostics.file.exists ? ( - - ) : ( - - )} -
- - - - - {diagnostics.file.parseError && ( -

- TOML warning: {diagnostics.file.parseError} -

- )} - {diagnostics.file.readError && ( -

- Read warning: {diagnostics.file.readError} -

- )} -
-
-
- - - - - - Current user-layer summary - - - - - - - - - - -
- - providers: {diagnostics.config.modelProviderCount} - - - profiles: {diagnostics.config.profileCount} - - - enabled features: {diagnostics.config.enabledFeatures.length} - - - MCP servers: {diagnostics.config.mcpServerCount} - -
- {diagnostics.config.topLevelKeys.length > 0 && ( -
-

- User-layer keys present -

-
- {diagnostics.config.topLevelKeys.map((key) => ( - - {key} - - ))} -
-
- )} -
-
- - - - - - - - Runtime vs provider - - - -
-

Native Codex runtime

-

- Use ccs-codex, ccsx, or{' '} - --target codex. CCS launches the local Codex CLI and depends on - native Codex capabilities such as --config overrides. -

-
-
-

Codex provider / bridge

-

- CCS can route provider credentials transiently through CLIProxy. That is not - the same as editing local config.toml, and some routed values - may never persist here. -

-
-
-
- - - - Supported flows - - - - - - Flow - Status - Notes - - - - {diagnostics.supportMatrix.map((entry) => ( - - {entry.label} - - - {entry.supported ? 'Yes' : 'No'} - - - - {entry.notes} - - - ))} - -
-
-
- - {diagnostics.warnings.length > 0 && ( - - - - - Warnings - - - - {diagnostics.warnings.map((warning) => ( -

- - {warning} -

- ))} -
-
- )} -
-
+
- -
- - - - - Structured controls boundary - - - -

- Guided controls write only the user-layer config.toml. They do - not model the full effective Codex runtime once trusted repo layers and CCS - transient -c overrides are involved. -

-

- Structured saves normalize TOML formatting and strip comments. Use the raw - editor on the right when exact layout matters. -

-
-
- - entry.name)} - disabled={structuredControlsDisabled} - disabledReason={controlsDisabledReason} - saving={isPatchingConfig} - onSave={(values) => - runConfigPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.') - } - /> - - - runConfigPatch( - { kind: 'project-trust', path: projectPath, trustLevel }, - trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.' - ) - } - /> - - entry.name)} - disabled={structuredControlsDisabled} - disabledReason={controlsDisabledReason} - saving={isPatchingConfig} - onSave={(name, values, setAsActive) => - runConfigPatch( - { kind: 'profile', action: 'upsert', name, values, setAsActive }, - 'Saved profile.' - ) - } - onDelete={(name) => - runConfigPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.') - } - onSetActive={(name) => - runConfigPatch( - { kind: 'profile', action: 'set-active', name }, - 'Set active profile.' - ) - } - /> - - - runConfigPatch( - { kind: 'model-provider', action: 'upsert', name, values }, - 'Saved model provider.' - ) - } - onDelete={(name) => - runConfigPatch( - { kind: 'model-provider', action: 'delete', name }, - 'Deleted model provider.' - ) - } - /> - - - runConfigPatch( - { kind: 'mcp-server', action: 'upsert', name, values }, - 'Saved MCP server.' - ) - } - onDelete={(name) => - runConfigPatch( - { kind: 'mcp-server', action: 'delete', name }, - 'Deleted MCP server.' - ) - } - /> - - - runConfigPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.') - } - /> -
-
+
- -
- - - - - Upstream notes - - - - {docsReference.notes.map((note, index) => ( -

- - {renderTextWithLinks(note)} -

- ))} - -
-

- Codex docs -

- -
- -
-

- Provider / bridge reference -

- -
- {docsReference.providerValues.length > 0 && ( - <> - -

- Provider values: {docsReference.providerValues.join(', ')} -

- - )} - {docsReference.settingsHierarchy.length > 0 && ( -

- Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')} -

- )} -
-
-
-
+
@@ -816,7 +193,7 @@ export function CodexPage() {
-
{renderOverview()}
+
{renderSidebar()}
From 09b7f66c0b69b31d2662f102fe2fb2f928cb24cd Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 11:51:15 -0400 Subject: [PATCH 09/10] style(codex): use monochrome sidebar icon - add a dedicated monochrome Codex sidebar asset - keep the existing colored Codex icons unchanged elsewhere --- ui/public/assets/sidebar/codex.svg | 1 + ui/src/components/layout/app-sidebar.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 ui/public/assets/sidebar/codex.svg diff --git a/ui/public/assets/sidebar/codex.svg b/ui/public/assets/sidebar/codex.svg new file mode 100644 index 00000000..df2ad17b --- /dev/null +++ b/ui/public/assets/sidebar/codex.svg @@ -0,0 +1 @@ +Codex diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index 4697aac6..963b1e9c 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -119,7 +119,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { title: t('nav.compatibleClis'), items: [ { path: '/claude-extension', icon: Puzzle, label: t('nav.claudeExtension') }, - { path: '/codex', iconSrc: '/assets/providers/codex-color.svg', label: 'Codex CLI' }, + { path: '/codex', iconSrc: '/assets/sidebar/codex.svg', label: 'Codex CLI' }, { path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }, ], }, From 9e43beec40608f178e502d1751723465189528a9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 14:18:51 -0400 Subject: [PATCH 10/10] feat(codex): harden runtime targeting and dashboard editing --- docs/code-standards.md | 6 +- docs/codebase-summary.md | 6 +- docs/project-roadmap.md | 2 +- docs/system-architecture/index.md | 2 +- docs/system-architecture/target-adapters.md | 28 +-- src/ccs.ts | 72 +++++- src/commands/help-command.ts | 4 +- src/shared/compatible-cli-contracts.ts | 1 + src/targets/codex-adapter.ts | 15 +- .../services/codex-dashboard-service.ts | 65 +++-- tests/unit/targets/codex-adapter.test.ts | 48 ++++ .../targets/codex-runtime-integration.test.ts | 197 +++++++++++++++ .../codex-dashboard-service.test.ts | 118 ++++++++- tests/unit/web-server/codex-routes.test.ts | 28 +++ .../codex-project-trust-card.tsx | 4 +- .../codex-top-level-controls-card.tsx | 37 ++- .../raw-json-settings-editor-panel.tsx | 19 +- ui/src/hooks/use-codex.ts | 1 + ui/src/hooks/use-droid.ts | 20 +- ui/src/lib/codex-config.ts | 5 +- ui/src/lib/i18n.ts | 8 +- ui/src/lib/support-updates-catalog.ts | 18 +- ui/src/pages/codex.tsx | 47 +++- .../codex-top-level-controls-card.test.tsx | 37 +++ ui/tests/unit/hooks/use-codex.test.tsx | 2 + .../ui/lib/support-updates-catalog.test.ts | 25 ++ ui/tests/unit/ui/pages/codex-page.test.tsx | 230 ++++++++++++++++++ 27 files changed, 932 insertions(+), 113 deletions(-) create mode 100644 tests/unit/targets/codex-runtime-integration.test.ts create mode 100644 ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx create mode 100644 ui/tests/unit/ui/lib/support-updates-catalog.test.ts create mode 100644 ui/tests/unit/ui/pages/codex-page.test.tsx diff --git a/docs/code-standards.md b/docs/code-standards.md index a3a3896b..76eae3d6 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -198,15 +198,15 @@ Resolves which adapter to use via `resolveTargetType()`: ``` 1. --target flag (highest priority) ↓ -2. Profile config: profileConfig.target field - ↓ -3. argv[0] detection (runtime alias pattern): +2. argv[0] detection (runtime alias pattern): - ccs-droid → droid - ccsd → droid - ccs-codex → codex - ccsx → codex - ccs → default ↓ +3. Profile config: profileConfig.target field + ↓ 4. Fallback: 'claude' (lowest priority) ``` diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 7224d95e..d682c11a 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -69,7 +69,7 @@ src/ │ ├── index.ts # Barrel export │ ├── target-adapter.ts # TargetAdapter interface contract │ ├── target-registry.ts # Registry for runtime adapter lookup -│ ├── target-resolver.ts # Resolution logic (flag > config > argv[0]) +│ ├── target-resolver.ts # Resolution logic (flag > argv[0] > config) │ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists │ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations │ ├── claude-adapter.ts # Claude Code CLI implementation @@ -254,7 +254,7 @@ src/ - Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection. - Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback. - Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch. -- Follow-up behavior: structured saves refresh the raw snapshot immediately, structured controls stay disabled while raw TOML is dirty or invalid, project trust paths must be absolute or `~/...`, and feature flags can be reset to default. +- Follow-up behavior: structured saves refresh the raw snapshot immediately, refresh discards stale raw drafts, structured controls stay disabled while raw TOML is dirty/invalid/unreadable, project trust paths must be absolute or `~/...`, unsupported upstream top-level shapes are preserved instead of deleted, and feature flags can be reset to default. - Supported Codex flows in v1: - `default` - CLIProxy provider `codex` @@ -279,8 +279,8 @@ The targets module provides an extensible interface for dispatching profiles to 2. **Target Resolution** - Priority order: - `--target ` flag (CLI argument) - - Per-profile `target` field (from config.yaml) - `argv[0]` detection (runtime alias pattern: `ccs-droid` / `ccsd` → droid) + - Per-profile `target` field (from config.yaml) - Default: `claude` 3. **Implementations:** diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8e4aa166..eee18ec2 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,7 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes -- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, dirty raw-editor guarding for structured controls, project-trust path validation, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. +- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. - **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. - **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts. - **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected. diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index afb9b624..ce103371 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -58,7 +58,7 @@ CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration w Profile Resolution (CLIProxy, Settings/API, Account-based) | v -Target Resolution (--target flag > config > argv[0] > default) +Target Resolution (--target flag > argv[0] > config > default) | v Get Target Adapter (Claude, Droid, or Codex) diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index c3de64f0..d78ae7e9 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -85,19 +85,19 @@ CCS resolves which adapter to use via priority-ordered checks: └─ ccs --target droid glm └─ ccs --target codex -2. Per-profile config (from ~/.ccs/config.yaml or settings.json) - └─ persisted targets are currently only `claude` and `droid` - └─ profiles: - glm: - target: droid - -3. argv[0] detection (runtime alias pattern) — binary name mapping +2. argv[0] detection (runtime alias pattern) — binary name mapping └─ ccs-droid (explicit alias) → droid └─ ccsd (legacy shortcut) → droid └─ ccs-codex (explicit alias) → codex └─ ccsx (short alias) → codex └─ ccs (regular command) → default +3. Per-profile config (from ~/.ccs/config.yaml or settings.json) + └─ persisted targets are currently only `claude` and `droid` + └─ profiles: + glm: + target: droid + 4. Fallback: 'claude' — lowest priority ``` @@ -117,18 +117,18 @@ export function resolveTargetType( return parsed.targetOverride; } - // 2. Check profile config - if (profileConfig?.target) { - // Persisted targets intentionally exclude runtime-only codex. - return profileConfig.target; - } - - // 3. Check argv[0] (binary name) + // 2. Check argv[0] (binary name) const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, ''); if (ARGV0_TARGET_MAP[binName]) { return ARGV0_TARGET_MAP[binName]; } + // 3. Check profile config + if (profileConfig?.target) { + // Persisted targets intentionally exclude runtime-only codex. + return profileConfig.target; + } + // 4. Default to claude return 'claude'; } diff --git a/src/ccs.ts b/src/ccs.ts index ba962e88..6b412371 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -90,8 +90,12 @@ interface DetectedProfile { interface RuntimeReasoningResolution { argsWithoutReasoningFlags: string[]; reasoningOverride: string | number | undefined; + reasoningSource: 'flag' | 'env' | undefined; + sourceDisplay: string | undefined; } +const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); + /** * Smart profile detection */ @@ -122,9 +126,21 @@ function resolveRuntimeReasoningFlags( return { argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags, reasoningOverride: runtime.reasoningOverride, + reasoningSource: runtime.sourceFlag + ? 'flag' + : runtime.reasoningOverride !== undefined + ? 'env' + : undefined, + sourceDisplay: runtime.sourceDisplay, }; } +function normalizeCodexRuntimeReasoningOverride( + value: string | number | undefined +): string | undefined { + return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined; +} + function exitWithRuntimeReasoningFlagError( message: string, options: { @@ -417,6 +433,9 @@ async function main(): Promise { // Resolve non-claude target adapter once. const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null; + let resolvedSettingsPath: string | undefined; + let resolvedSettings: ReturnType | undefined; + let resolvedCliproxyBridge: ReturnType | undefined; // Preflight unsupported profile/target combinations BEFORE binary detection, // so users get the most actionable error even when the target CLI is not installed. @@ -426,7 +445,29 @@ async function main(): Promise { process.exit(1); } - if (profileInfo.type !== 'settings') { + if (profileInfo.type === 'settings') { + resolvedSettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name); + resolvedSettings = loadSettings(resolvedSettingsPath); + resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings); + const compatibility = evaluateTargetRuntimeCompatibility({ + target: resolvedTarget, + profileType: profileInfo.type, + cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, + }); + if (!compatibility.supported) { + console.error( + fail( + compatibility.reason || `${targetAdapter.displayName} does not support this profile.` + ) + ); + if (compatibility.suggestion) { + console.error(info(compatibility.suggestion)); + } + process.exit(1); + } + } else { const compatibility = evaluateTargetRuntimeCompatibility({ target: resolvedTarget, profileType: profileInfo.type, @@ -524,7 +565,7 @@ async function main(): Promise { } catch (error) { if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { exitWithRuntimeReasoningFlagError(error.message, { - codexAliasLevels: 'medium|high|xhigh', + codexAliasLevels: 'minimal|low|medium|high|xhigh', includeDroidExecExample: true, }); } @@ -534,7 +575,20 @@ async function main(): Promise { try { const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); targetRemainingArgs = runtime.argsWithoutReasoningFlags; - runtimeReasoningOverride = runtime.reasoningOverride; + const normalizedReasoning = normalizeCodexRuntimeReasoningOverride( + runtime.reasoningOverride + ); + if (runtime.reasoningOverride !== undefined && !normalizedReasoning) { + if (runtime.reasoningSource === 'flag') { + throw new DroidReasoningFlagError( + 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.', + '--effort' + ); + } + runtimeReasoningOverride = undefined; + } else { + runtimeReasoningOverride = normalizedReasoning; + } } catch (error) { if (error instanceof DroidReasoningFlagError) { exitWithRuntimeReasoningFlagError(error.message, { @@ -777,11 +831,13 @@ async function main(): Promise { ); } const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; - const expandedSettingsPath = profileInfo.settingsPath - ? expandPath(profileInfo.settingsPath) - : getSettingsPath(profileInfo.name); - const settings = loadSettings(expandedSettingsPath); - const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); + const expandedSettingsPath = + resolvedSettingsPath ?? + (profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name)); + const settings = resolvedSettings ?? loadSettings(expandedSettingsPath); + const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings); if (resolvedTarget !== 'claude') { const compatibility = evaluateTargetRuntimeCompatibility({ target: resolvedTarget, diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 11d7dad5..0d76bd59 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -236,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'ccs --thinking ', 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', ], - ['ccs codex --effort ', 'Set codex reasoning effort (medium/high/xhigh)'], + ['ccs codex --effort ', 'Set codex reasoning effort (minimal/low/medium/high/xhigh)'], ['ccs --1m', 'Request explicit 1M context when the selected model supports [1m]'], ['ccs --no-1m', 'Force standard context / clear [1m]'], ['ccs --logout', 'Clear authentication'], @@ -523,7 +523,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['--thinking xhigh', '32K tokens - Maximum depth'], ['--thinking ', 'Custom token budget (512-100000)'], ['', ''], - ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], + ['--effort ', 'Codex alias for reasoning effort (minimal/low/medium/high/xhigh)'], ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], ['', ''], ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], diff --git a/src/shared/compatible-cli-contracts.ts b/src/shared/compatible-cli-contracts.ts index 8d9583c5..0c0f1b42 100644 --- a/src/shared/compatible-cli-contracts.ts +++ b/src/shared/compatible-cli-contracts.ts @@ -131,6 +131,7 @@ export interface CodexRawConfigResponse { rawText: string; config: Record | null; parseError: string | null; + readError: string | null; } export interface CodexTopLevelSettingsPatch { diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index eedaf554..859ec7d3 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -28,6 +28,13 @@ 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 findDisallowedCodexManagedFlags(args: string[]): string[] { const disallowed = new Set(); @@ -91,6 +98,9 @@ export class CodexAdapter implements TargetAdapter { if (profileType === 'default') { if (reasoningOverride) { + if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + throw buildConfigOverrideSupportError(options?.binaryInfo); + } return [ ...buildConfigOverrideArgs([ `model_reasoning_effort=${formatTomlString(reasoningOverride)}`, @@ -102,10 +112,7 @@ export class CodexAdapter implements TargetAdapter { } if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { - const versionSummary = options?.binaryInfo?.version ? ` (${options.binaryInfo.version})` : ''; - throw new Error( - `Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles.` - ); + throw buildConfigOverrideSupportError(options?.binaryInfo); } if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) { diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index 3ebabb1f..505c84b1 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -70,8 +70,8 @@ const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']); const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']); const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']); -const PERSONALITY_VALUES = new Set(['default', 'pragmatic', 'concise', 'direct']); -const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'ask']); +const PERSONALITY_VALUES = new Set(['none', 'friendly', 'pragmatic']); +const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'untrusted']); function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -113,9 +113,20 @@ function deleteIfEmpty(target: Record, key: string) { } } +function shouldPreserveUnsupportedValue(value: unknown): boolean { + return Array.isArray(value) || isObject(value); +} + +function deleteFieldUnlessUnsupported(target: Record, key: string) { + if (shouldPreserveUnsupportedValue(target[key])) { + return; + } + delete target[key]; +} + function setStringField(target: Record, key: string, value: unknown) { if (!isNonEmptyString(value)) { - delete target[key]; + deleteFieldUnlessUnsupported(target, key); return; } target[key] = value.trim(); @@ -129,7 +140,7 @@ function setEnumStringField( label: string ) { if (!isNonEmptyString(value)) { - delete target[key]; + deleteFieldUnlessUnsupported(target, key); return; } @@ -145,7 +156,7 @@ function setEnumStringField( function setBooleanField(target: Record, key: string, value: unknown) { if (typeof value !== 'boolean') { - delete target[key]; + deleteFieldUnlessUnsupported(target, key); return; } target[key] = value; @@ -158,7 +169,7 @@ function setNumberField( options: { integer?: boolean; min?: number } = {} ) { if (typeof value !== 'number' || !Number.isFinite(value)) { - delete target[key]; + deleteFieldUnlessUnsupported(target, key); return; } @@ -199,6 +210,24 @@ function assertPatchableToml(fileProbe: { return asObject(fileProbe.config) ?? {}; } +function summarizeApprovalPolicy(value: unknown): string | null { + const stringValue = asString(value); + if (stringValue) { + return stringValue; + } + + const objectValue = asObject(value); + if (!objectValue) { + return null; + } + + if (hasOwn(objectValue, 'granular')) { + return 'granular (custom)'; + } + + return 'custom object'; +} + function applyTopLevelSettingsPatch( target: Record, values: Extract['values'] @@ -460,16 +489,11 @@ function applyMcpServerPatch( if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled); if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required); if (hasOwn(values, 'startupTimeoutSec')) { - setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, { - integer: true, - min: 1, - }); + delete nextServer.startup_timeout_ms; + setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, { min: 1 }); } if (hasOwn(values, 'toolTimeoutSec')) { - setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, { - integer: true, - min: 1, - }); + setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, { min: 1 }); } if (hasOwn(values, 'enabledTools')) { const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools'); @@ -506,7 +530,9 @@ export function resolveCodexConfigPaths( ): CodexConfigPaths { const env = options.env ?? process.env; const homeDir = options.homeDir ?? os.homedir(); - const baseDir = env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex'); + const baseDir = path.resolve( + env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex') + ); const baseDirDisplay = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex'; return { @@ -596,7 +622,8 @@ export function summarizeCodexMcpServers(value: unknown): CodexMcpServerDiagnost const startupTimeoutMs = asNumber(server.startup_timeout_ms); const startupTimeoutSec = - asNumber(server.startup_timeout_sec) ?? (startupTimeoutMs ? startupTimeoutMs / 1000 : null); + asNumber(server.startup_timeout_sec) ?? + (startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null); return { name, @@ -727,7 +754,7 @@ export async function getCodexDashboardDiagnostics(): Promise { rawText: fileProbe.rawText, config: fileProbe.config, parseError: fileProbe.diagnostics.parseError, + readError: fileProbe.diagnostics.readError, }; } @@ -827,7 +855,7 @@ export async function patchCodexConfig( const saved = await writeTomlFileAtomic({ filePath: paths.configPath, rawText, - expectedMtime: input.expectedMtime, + expectedMtime: input.expectedMtime ?? fileProbe.diagnostics.mtimeMs ?? undefined, fileLabel: 'config.toml', }); @@ -840,5 +868,6 @@ export async function patchCodexConfig( rawText, config: nextConfig, parseError: null, + readError: null, }; } diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 0889219b..57214f77 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -30,11 +30,36 @@ describe('CodexAdapter', () => { apiKey: '', reasoningOverride: 'medium', }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, }); expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']); }); + test('rejects default-mode reasoning overrides when codex lacks config override support', () => { + expect(() => + adapter.buildArgs('default', ['--search'], { + profileType: 'default', + creds: { + profile: 'default', + baseUrl: '', + apiKey: '', + reasoningOverride: 'high', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + version: 'codex-cli 0.1.0', + features: [], + }, + }) + ).toThrow(/does not advertise --config overrides/); + }); + test('injects transient config overrides for CCS-backed launches', () => { const args = adapter.buildArgs('codex', ['--search'], { profileType: 'cliproxy', @@ -97,6 +122,29 @@ describe('CodexAdapter', () => { ).toThrow(/does not allow --profile\/-p/); }); + test('rejects user-supplied --config overrides for CCS-backed launches', () => { + const options = { + profileType: 'cliproxy' as const, + creds: { + profile: 'codex', + baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + apiKey: 'cliproxy-token', + }, + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + features: ['config-overrides'], + }, + }; + + expect(() => adapter.buildArgs('codex', ['-c', 'model="other"', '--search'], options)).toThrow( + /does not allow --config\/-c/ + ); + expect(() => + adapter.buildArgs('codex', ['--config=model="other"', '--search'], options) + ).toThrow(/does not allow --config\/-c/); + }); + test('rejects unsupported reasoning override values for CCS-backed launches', () => { expect(() => adapter.buildArgs('codex', ['--search'], { diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts new file mode 100644 index 00000000..f7e0e2c0 --- /dev/null +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...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 []; + } + + return fs + .readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); +} + +describe('codex runtime integration', () => { + let tmpHome: string; + let ccsDir: string; + let fakeCodexPath: string; + let codexArgsLogPath: string; + let emptyPathDir: string; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-route-it-')); + ccsDir = path.join(tmpHome, '.ccs'); + fakeCodexPath = path.join(tmpHome, 'fake-codex.js'); + codexArgsLogPath = path.join(tmpHome, 'codex-args.log'); + emptyPathDir = path.join(tmpHome, 'empty-bin'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.mkdirSync(emptyPathDir, { recursive: true }); + + fs.writeFileSync( + fakeCodexPath, + `#!/usr/bin/env node +const fs = require('fs'); +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') { + process.stdout.write(process.env.CCS_TEST_CODEX_VERSION || 'codex-cli 0.118.0-alpha.3'); + process.exit(0); +} +if (process.argv[2] === '--help') { + process.stdout.write( + process.env.CCS_TEST_CODEX_HELP || + ' -c, --config \\n -p, --profile \\n' + ); + process.exit(0); +} +process.exit(0); +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeCodexPath, 0o755); + }); + + afterEach(() => { + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('ignores numeric CCS_THINKING env overrides for native Codex default mode', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['default', '--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, + CCS_THINKING: '8192', + }); + + expect(result.status).toBe(0); + const calls = readLoggedCodexCalls(codexArgsLogPath); + expect(calls.at(-1)).toEqual(['fix failing tests']); + }); + + it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['default', '--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, + CCS_THINKING: 'off', + }); + + expect(result.status).toBe(0); + const calls = readLoggedCodexCalls(codexArgsLogPath); + expect(calls.at(-1)).toEqual(['fix failing tests']); + }); + + it('fails fast when native Codex reasoning overrides need unsupported --config support', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_HELP: ' -p, --profile \\n', + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('does not advertise --config overrides'); + const calls = readLoggedCodexCalls(codexArgsLogPath); + expect(calls).toEqual([['--version'], ['--help']]); + }); + + it('reports unsupported generic settings profiles before Codex install guidance', () => { + if (process.platform === 'win32') return; + + const settingsPath = path.join(ccsDir, 'myglm.settings.json'); + const configPath = path.join(ccsDir, 'config.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://example.invalid/anthropic', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'gpt-5.4', + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + configPath, + JSON.stringify( + { + profiles: { + myglm: settingsPath, + }, + }, + null, + 2 + ) + ); + + const result = runCcs(['myglm', '--target', 'codex', 'fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + PATH: emptyPathDir, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + 'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.' + ); + expect(result.stderr).not.toContain('Install a recent @openai/codex build'); + }); +}); diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index ac09c04b..f5950d91 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -62,14 +62,14 @@ describe('codex-dashboard-service', () => { it('resolves codex config paths with CODEX_HOME override', () => { const resolved = resolveCodexConfigPaths({ env: { - CODEX_HOME: '/tmp/custom-codex-home', + CODEX_HOME: './custom-codex-home', } as NodeJS.ProcessEnv, homeDir: '/Users/tester', }); - expect(resolved.baseDir).toBe('/tmp/custom-codex-home'); + expect(resolved.baseDir).toBe(path.resolve('./custom-codex-home')); expect(resolved.baseDirDisplay).toBe('$CODEX_HOME'); - expect(resolved.configPath).toBe('/tmp/custom-codex-home/config.toml'); + expect(resolved.configPath).toBe(path.join(path.resolve('./custom-codex-home'), 'config.toml')); expect(resolved.configDisplayPath).toBe('$CODEX_HOME/config.toml'); }); @@ -103,7 +103,7 @@ describe('codex-dashboard-service', () => { }); const projects = summarizeCodexProjectTrust({ '/tmp/a': { trust_level: 'trusted' }, - '/tmp/b': { trust_level: 'ask' }, + '/tmp/b': { trust_level: 'untrusted' }, }); const servers = summarizeCodexMcpServers({ stdio: { @@ -133,6 +133,7 @@ describe('codex-dashboard-service', () => { expect(raw.path).toBe('$CODEX_HOME/config.toml'); expect(raw.rawText).toBe(''); expect(raw.config).toBeNull(); + expect(raw.readError).toBeNull(); }); it('returns parseError when config.toml is invalid TOML', async () => { @@ -145,6 +146,19 @@ describe('codex-dashboard-service', () => { expect(raw.config).toBeNull(); }); + it('returns readError when config.toml is a symlink', async () => { + const configPath = path.join(codexHome, 'config.toml'); + const targetPath = path.join(testRoot, 'linked.toml'); + fs.writeFileSync(targetPath, 'model = "gpt-5.4"\n'); + fs.symlinkSync(targetPath, configPath); + + const raw = await getCodexRawConfig(); + + expect(raw.exists).toBe(true); + expect(raw.readError).toContain('Refusing symlink file'); + expect(raw.config).toBeNull(); + }); + it('includes docs links, support matrix, and config summaries in diagnostics', async () => { fs.writeFileSync( path.join(codexHome, 'config.toml'), @@ -170,7 +184,7 @@ wire_api = "responses" trust_level = "trusted" [projects."/tmp/project-b"] -trust_level = "ask" +trust_level = "untrusted" [mcp_servers.playwright] command = "npx" @@ -204,6 +218,17 @@ model = "gpt-5.4" expect(diagnostics.supportMatrix.some((entry) => entry.id === 'default')).toBe(true); }); + it('summarizes granular approval policies without flattening them to null', async () => { + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + 'approval_policy = { granular = { edit = "on-request" } }\n' + ); + + const diagnostics = await getCodexDashboardDiagnostics(); + + expect(diagnostics.config.approvalPolicy).toBe('granular (custom)'); + }); + it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => { writeCodexStub({ helpText: ' -p, --profile \n' }); fs.writeFileSync( @@ -289,7 +314,7 @@ bearer_token = "secret" sandboxMode: 'workspace-write', webSearch: 'cached', toolOutputTokenLimit: 12000, - personality: 'pragmatic', + personality: 'friendly', }, }); @@ -304,12 +329,51 @@ bearer_token = "secret" expect(diagnostics.config.model).toBe('gpt-5.4'); expect(diagnostics.config.modelReasoningEffort).toBe('high'); expect(diagnostics.config.toolOutputTokenLimit).toBe(12000); - expect(diagnostics.config.personality).toBe('pragmatic'); + expect(diagnostics.config.personality).toBe('friendly'); expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a'); expect(result.rawText).toContain('model = "gpt-5.4"'); expect(result.config?.model).toBe('gpt-5.4'); }); + it('allows structured patches on existing config.toml even when expectedMtime is omitted', async () => { + fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n'); + + const result = await patchCodexConfig({ + kind: 'feature', + feature: 'multi_agent', + enabled: true, + }); + + expect(result.rawText).toContain('model = "gpt-5.4"'); + expect(result.rawText).toContain('[features]'); + expect(result.rawText).toContain('multi_agent = true'); + expect(result.config?.features).toEqual({ multi_agent: true }); + }); + + it('preserves unsupported approval_policy objects when structured saves touch other fields', async () => { + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + 'model = "gpt-5.4"\napproval_policy = { granular = { edit = "on-request" } }\n' + ); + + const current = await getCodexRawConfig(); + const result = await patchCodexConfig({ + kind: 'top-level', + expectedMtime: current.mtime, + values: { + model: 'gpt-5.4-mini', + approvalPolicy: null, + }, + }); + + expect(result.rawText).toContain('model = "gpt-5.4-mini"'); + expect(result.rawText).toContain('[approval_policy.granular]'); + expect(result.rawText).toContain('edit = "on-request"'); + expect(result.config?.approval_policy).toEqual({ + granular: { edit: 'on-request' }, + }); + }); + it('expands home paths for project trust and rejects relative paths', async () => { const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace'); const expanded = await patchCodexConfig({ @@ -384,6 +448,46 @@ bearer_token = "secret" expect(profileResult.config?.profile).toBe('deep-review'); }); + it('accepts non-integer MCP timeout values documented by upstream Codex', async () => { + const result = await patchCodexConfig({ + kind: 'mcp-server', + action: 'upsert', + name: 'streaming', + values: { + transport: 'stdio', + command: 'npx', + startupTimeoutSec: 1.5, + toolTimeoutSec: 2.25, + }, + }); + + expect(result.rawText).toContain('startup_timeout_sec = 1.5'); + expect(result.rawText).toContain('tool_timeout_sec = 2.25'); + }); + + it('rewrites legacy startup_timeout_ms keys when editing MCP server timeouts', async () => { + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + ['[mcp_servers.streaming]', 'command = "npx"', 'startup_timeout_ms = 1500', ''].join('\n') + ); + const raw = await getCodexRawConfig(); + + const result = await patchCodexConfig({ + kind: 'mcp-server', + action: 'upsert', + name: 'streaming', + expectedMtime: raw.mtime, + values: { + transport: 'stdio', + command: 'npx', + startupTimeoutSec: 2.5, + }, + }); + + expect(result.rawText).toContain('startup_timeout_sec = 2.5'); + expect(result.rawText).not.toContain('startup_timeout_ms'); + }); + it('patches streamable-http mcp servers through structured controls', async () => { const result = await patchCodexConfig({ kind: 'mcp-server', diff --git a/tests/unit/web-server/codex-routes.test.ts b/tests/unit/web-server/codex-routes.test.ts index 78083ef9..00166fcc 100644 --- a/tests/unit/web-server/codex-routes.test.ts +++ b/tests/unit/web-server/codex-routes.test.ts @@ -76,12 +76,14 @@ describe('codex routes', () => { rawText: string; config: Record | null; parseError: string | null; + readError: string | null; }; expect(json.success).toBe(true); expect(json.exists).toBe(true); expect(json.mtime).toBeGreaterThan(0); expect(json.parseError).toBeNull(); + expect(json.readError).toBeNull(); expect(json.rawText).toContain('model = "gpt-5.4"'); expect(json.rawText).toContain('sandbox_mode = "workspace-write"'); expect(json.config?.model).toBe('gpt-5.4'); @@ -112,6 +114,32 @@ describe('codex routes', () => { expect(json.mtime).toBeGreaterThan(0); }); + it('allows PATCH /config/patch on an existing config.toml without expectedMtime', async () => { + fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n'); + + const res = await fetch(`${baseUrl}/api/codex/config/patch`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: 'feature', + feature: 'multi_agent', + enabled: true, + }), + }); + + expect(res.status).toBe(200); + + const json = (await res.json()) as { + success: boolean; + rawText: string; + config: Record | null; + }; + + expect(json.success).toBe(true); + expect(json.rawText).toContain('multi_agent = true'); + expect(json.config?.features).toEqual({ multi_agent: true }); + }); + it('returns 400 when PATCH /config/patch omits kind', async () => { const res = await fetch(`${baseUrl}/api/codex/config/patch`, { method: 'PATCH', diff --git a/ui/src/components/compatible-cli/codex-project-trust-card.tsx b/ui/src/components/compatible-cli/codex-project-trust-card.tsx index f9779f60..7d265080 100644 --- a/ui/src/components/compatible-cli/codex-project-trust-card.tsx +++ b/ui/src/components/compatible-cli/codex-project-trust-card.tsx @@ -51,7 +51,7 @@ function ProjectTrustComposer({ trusted - ask + untrusted
- @@ -264,7 +289,7 @@ export function CodexTopLevelControlsCard({ title="Top-level controls" badge="config.toml" icon={} - description="Structured controls for the stable top-level Codex settings users touch most often." + description="Structured controls for the stable top-level Codex settings users touch most often. Unsupported upstream shapes stay untouched and should be edited in raw TOML." disabledReason={disabledReason} > void; onSave: () => Promise | void; onRefresh: () => Promise | void; + onDiscard?: () => void; language?: 'json' | 'yaml' | 'toml'; loadingLabel?: string; parseWarningLabel?: string; @@ -30,13 +33,16 @@ export function RawConfigEditorPanel({ pathLabel, loading, parseWarning, + readWarning, value, dirty, + readOnly = false, saving, saveDisabled, onChange, onSave, onRefresh, + onDiscard, language = 'json', loadingLabel = 'Loading settings.json...', parseWarningLabel = 'Parse warning', @@ -76,11 +82,16 @@ export function RawConfigEditorPanel({ )} Save + {onDiscard ? ( + + ) : null} -
@@ -100,12 +111,18 @@ export function RawConfigEditorPanel({ {parseWarningLabel}: {parseWarning}
)} + {readWarning && ( +
+ Read-only: {readWarning} +
+ )}
diff --git a/ui/src/hooks/use-codex.ts b/ui/src/hooks/use-codex.ts index 2291ea1d..a9f7ec43 100644 --- a/ui/src/hooks/use-codex.ts +++ b/ui/src/hooks/use-codex.ts @@ -97,6 +97,7 @@ export function useCodex() { rawText: variables.rawText, config: parsed.config, parseError: parsed.parseError, + readError: null, }; }); queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] }); diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index c35a3232..1a71f8a3 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -1,6 +1,10 @@ import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApiConflictError, withApiBase } from '@/lib/api-client'; +import type { + CompatibleCliDocLink, + CompatibleCliProviderDocLink, +} from '@shared/compatible-cli-contracts'; export interface DroidBinaryDiagnostics { installed: boolean; @@ -36,22 +40,6 @@ export interface DroidCustomModelDiagnostics { apiKeyPreview: string | null; } -export interface CompatibleCliDocLink { - id: string; - label: string; - url: string; - category: 'overview' | 'configuration' | 'byok' | 'reference'; - source: 'factory' | 'provider'; - description: string; -} - -export interface CompatibleCliProviderDocLink { - provider: string; - label: string; - apiFormat: string; - url: string; -} - export interface DroidDashboardDiagnostics { binary: DroidBinaryDiagnostics; files: { diff --git a/ui/src/lib/codex-config.ts b/ui/src/lib/codex-config.ts index a2ece0c4..b13a16b8 100644 --- a/ui/src/lib/codex-config.ts +++ b/ui/src/lib/codex-config.ts @@ -184,6 +184,7 @@ export function readCodexMcpServers(config: Record | null): Cod const server = asObject(value); if (!server) return null; const transport = asString(server.command) ? 'stdio' : 'streamable-http'; + const startupTimeoutMs = asNumber(server.startup_timeout_ms); return { name, transport, @@ -192,7 +193,9 @@ export function readCodexMcpServers(config: Record | null): Cod url: asString(server.url), enabled: server.enabled !== false, required: server.required === true, - startupTimeoutSec: asNumber(server.startup_timeout_sec), + startupTimeoutSec: + asNumber(server.startup_timeout_sec) ?? + (startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null), toolTimeoutSec: asNumber(server.tool_timeout_sec), enabledTools: asStringArray(server.enabled_tools), disabledTools: asStringArray(server.disabled_tools), diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index b5c14ad7..f825d60f 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -839,7 +839,7 @@ const resources = { supportLine1Suffix: '(token-based)', supportLine2Prefix: 'Reasoning effort:', supportLine2SuffixPrefix: '(suffix or ', - supportLine2SuffixPostfix: ': medium/high/xhigh)', + supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)', supportLine3Prefix: 'Codex suffixes pin effort (for example ', supportLine3Suffix: '); unsuffixed models use Thinking mode.', }, @@ -2012,7 +2012,7 @@ const resources = { supportLine1Suffix: '(基于 token)', supportLine2Prefix: '推理强度:', supportLine2SuffixPrefix: '(后缀或 ', - supportLine2SuffixPostfix: ':medium/high/xhigh)', + supportLine2SuffixPostfix: ':minimal/low/medium/high/xhigh)', supportLine3Prefix: 'Codex 后缀会固定强度(例如 ', supportLine3Suffix: ');无后缀模型使用 Thinking mode。', }, @@ -3232,7 +3232,7 @@ const resources = { supportLine1Suffix: '(dựa trên token)', supportLine2Prefix: 'Nỗ lực lý luận:', supportLine2SuffixPrefix: '(hậu tố hoặc ', - supportLine2SuffixPostfix: ': medium/high/xhigh)', + supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)', supportLine3Prefix: 'Hậu tố Codex cố định mức effort (ví dụ ', supportLine3Suffix: '); model không hậu tố sẽ dùng chế độ Thinking.', }, @@ -4469,7 +4469,7 @@ const resources = { supportLine1Suffix: '(トークンベース)', supportLine2Prefix: '推論強度:', supportLine2SuffixPrefix: '(サフィックス、または ', - supportLine2SuffixPostfix: ': medium/high/xhigh)', + supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)', supportLine3Prefix: 'Codex のサフィックスは推論強度を固定します(例: ', supportLine3Suffix: ')。サフィックスなしのモデルは思考モードを使います。', }, diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index bd3c8066..67a82731 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -87,17 +87,14 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ command: 'ccs codex --target codex "your prompt"', }, { - id: 'open-cliproxy-codex', - label: 'Open Codex provider settings', - description: 'Review Codex provider and bridge flows in the dashboard.', + id: 'open-codex-dashboard', + label: 'Open Codex dashboard', + description: 'Review Codex runtime support, config layers, and dashboard setup flows.', type: 'route', - path: '/cliproxy', + path: '/codex', }, ], - routes: [ - { label: 'CLIProxy', path: '/cliproxy' }, - { label: 'API Profiles', path: '/providers' }, - ], + routes: [{ label: 'Codex CLI', path: '/codex' }], commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex "your prompt"'], }, { @@ -243,10 +240,7 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ auth: 'Native Codex auth for default mode, env_key injection for CCS-backed routes', model: 'Native Codex config or routed Codex model mapping from CLIProxy', }, - routes: [ - { label: 'CLIProxy', path: '/cliproxy' }, - { label: 'API Profiles', path: '/providers' }, - ], + routes: [{ label: 'Codex CLI', path: '/codex' }], commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'], notes: 'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.', diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx index 556c3dab..07756061 100644 --- a/ui/src/pages/codex.tsx +++ b/ui/src/pages/codex.tsx @@ -46,16 +46,22 @@ export function CodexPage() { : { valid: true as const }; const controlsConfig = rawConfig?.config ?? null; const structuredControlsDisabled = - rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null; + rawConfigLoading || + !rawConfig || + rawConfigDirty || + rawConfig?.parseError !== null || + rawConfig?.readError !== null; const controlsDisabledReason = rawConfigError ? 'Structured controls unavailable: failed to load the current config.toml.' - : rawConfigDirty - ? rawEditorValidation.valid - ? 'Save or discard raw TOML edits before using structured controls.' - : 'Fix or discard raw TOML edits before using structured controls.' - : rawConfig?.parseError - ? `Structured controls disabled: ${rawConfig.parseError}` - : null; + : rawConfig?.readError + ? `Structured controls unavailable: ${rawConfig.readError}` + : rawConfigDirty + ? rawEditorValidation.valid + ? 'Save or discard raw TOML edits before using structured controls.' + : 'Fix or discard raw TOML edits before using structured controls.' + : rawConfig?.parseError + ? `Structured controls disabled: ${rawConfig.parseError}` + : null; const topLevelSettings = useMemo( () => readCodexTopLevelSettings(controlsConfig), @@ -82,7 +88,21 @@ export function CodexPage() { }; const refreshAll = async () => { - await Promise.all([refetchDiagnostics(), refetchRawConfig()]); + try { + const results = await Promise.all([refetchDiagnostics(), refetchRawConfig()]); + const refreshFailed = results.some( + (result) => !result || result.status === 'error' || result.isError || result.error + ); + + if (refreshFailed) { + toast.error('Failed to refresh Codex snapshot. Raw edits were kept.'); + return; + } + + setRawDraftText(null); + } catch (error) { + toast.error((error as Error).message || 'Failed to refresh Codex snapshot.'); + } }; const handleSaveRawConfig = async () => { @@ -206,17 +226,24 @@ export function CodexPage() { parseWarning={ rawEditorValidation.valid ? rawConfig?.parseError : rawEditorValidation.error } + readWarning={rawConfig?.readError} value={rawEditorText} dirty={rawConfigDirty} + readOnly={Boolean(rawConfig?.readError)} saving={isSavingRawConfig} saveDisabled={ - !rawConfigDirty || isSavingRawConfig || rawConfigLoading || !rawEditorValidation.valid + !rawConfigDirty || + isSavingRawConfig || + rawConfigLoading || + !rawEditorValidation.valid || + Boolean(rawConfig?.readError) } onChange={(next) => { setRawEditorDraftText(next); }} onSave={handleSaveRawConfig} onRefresh={refreshAll} + onDiscard={() => setRawDraftText(null)} language="toml" loadingLabel="Loading config.toml..." parseWarningLabel="TOML warning" diff --git a/ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx b/ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx new file mode 100644 index 00000000..91b4cebb --- /dev/null +++ b/ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; +import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card'; + +describe('CodexTopLevelControlsCard', () => { + it('submits only changed fields so untouched unsupported values are preserved upstream', async () => { + const onSave = vi.fn(); + + render( + + ); + + const saveButton = screen.getByRole('button', { name: 'Save top-level settings' }); + expect(saveButton).toBeDisabled(); + + await userEvent.type(screen.getByPlaceholderText('gpt-5.4'), 'gpt-5.4-mini'); + expect(saveButton).toBeEnabled(); + + await userEvent.click(saveButton); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith({ model: 'gpt-5.4-mini' }); + }); +}); diff --git a/ui/tests/unit/hooks/use-codex.test.tsx b/ui/tests/unit/hooks/use-codex.test.tsx index 0d3bfb67..15340dee 100644 --- a/ui/tests/unit/hooks/use-codex.test.tsx +++ b/ui/tests/unit/hooks/use-codex.test.tsx @@ -77,6 +77,7 @@ const initialRawConfigResponse = { rawText: 'model = "gpt-5.3-codex"\n', config: { model: 'gpt-5.3-codex' }, parseError: null, + readError: null, }; const patchedRawConfigResponse = { @@ -88,6 +89,7 @@ const patchedRawConfigResponse = { rawText: 'model = "gpt-5.4"\n', config: { model: 'gpt-5.4' }, parseError: null, + readError: null, }; const wrapper = ({ children }: { children: ReactNode }) => {children}; diff --git a/ui/tests/unit/ui/lib/support-updates-catalog.test.ts b/ui/tests/unit/ui/lib/support-updates-catalog.test.ts new file mode 100644 index 00000000..b7e69f84 --- /dev/null +++ b/ui/tests/unit/ui/lib/support-updates-catalog.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { CLI_SUPPORT_ENTRIES, SUPPORT_NOTICES } from '@/lib/support-updates-catalog'; + +describe('support-updates catalog codex routing', () => { + it('routes the Codex runtime notice to the Codex dashboard', () => { + const notice = SUPPORT_NOTICES.find((entry) => entry.id === 'codex-target-runtime-support'); + + expect(notice).toBeDefined(); + expect(notice?.routes).toContainEqual({ label: 'Codex CLI', path: '/codex' }); + expect(notice?.actions).toContainEqual( + expect.objectContaining({ + id: 'open-codex-dashboard', + type: 'route', + path: '/codex', + }) + ); + }); + + it('routes the Codex target entry to the Codex dashboard', () => { + const entry = CLI_SUPPORT_ENTRIES.find((item) => item.id === 'codex-target'); + + expect(entry).toBeDefined(); + expect(entry?.routes).toEqual([{ label: 'Codex CLI', path: '/codex' }]); + }); +}); diff --git a/ui/tests/unit/ui/pages/codex-page.test.tsx b/ui/tests/unit/ui/pages/codex-page.test.tsx new file mode 100644 index 00000000..cf782385 --- /dev/null +++ b/ui/tests/unit/ui/pages/codex-page.test.tsx @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +const mocks = vi.hoisted(() => ({ + useCodex: vi.fn(), + refetchDiagnostics: vi.fn(), + refetchRawConfig: vi.fn(), + saveRawConfigAsync: vi.fn(), + patchConfigAsync: vi.fn(), +})); + +vi.mock('@/hooks/use-codex', () => ({ + useCodex: mocks.useCodex, +})); + +vi.mock('react-resizable-panels', () => ({ + PanelGroup: ({ children }: { children: ReactNode }) =>
{children}
, + Panel: ({ children }: { children: ReactNode }) =>
{children}
, + PanelResizeHandle: () =>
, +})); + +vi.mock('@/components/shared/code-editor', () => ({ + CodeEditor: ({ + value, + onChange, + readonly, + }: { + value: string; + onChange: (next: string) => void; + readonly?: boolean; + }) => ( +