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'"); + }); });