mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat: add codex dashboard parity
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
+11
-8
@@ -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 <args>` | `droid -m custom:ccs-<profile> <args>` |
|
||||
| **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 <args>` | `droid -m custom:ccs-<profile> <args>` | `codex <args>` or `codex -c ... <args>` |
|
||||
| **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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<profile> <args>`
|
||||
- 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | 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<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function parseTransport(server: Record<string, unknown>): 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<CodexDashboardDiagnostics> {
|
||||
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<CodexRawConfigResponse> {
|
||||
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<SaveCodexRawConfigResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, CompatibleCliDocsRegistryEntr
|
||||
],
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
cliId: 'codex',
|
||||
displayName: 'Codex CLI',
|
||||
docsReference: {
|
||||
providerValues: ['openai', 'oss', 'custom model_providers'],
|
||||
settingsHierarchy: [
|
||||
'system managed config',
|
||||
'user config ($CODEX_HOME/config.toml)',
|
||||
'cwd config',
|
||||
'tree/repo config',
|
||||
'CLI -c overrides and environment variables',
|
||||
],
|
||||
notes: [
|
||||
'User config lives at ~/.codex/config.toml unless CODEX_HOME overrides the base directory',
|
||||
'Codex merges multiple config layers; this dashboard edits only the user layer',
|
||||
'CLI --profile selects a named [profiles.<name>] 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 {
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function statPath(filePath: string): Promise<import('fs').Stats | null> {
|
||||
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<string, unknown> {
|
||||
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<TomlFileProbe> {
|
||||
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<WriteTomlFileResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
@@ -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 <key=value>\n -p, --profile <CONFIG_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 <CONFIG_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);
|
||||
});
|
||||
});
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/codex"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<CodexPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/droid"
|
||||
element={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -7,7 +7,7 @@ import { CodeEditor } from '@/components/shared/code-editor';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RawJsonSettingsEditorPanelProps {
|
||||
interface RawConfigEditorPanelProps {
|
||||
title: string;
|
||||
pathLabel: string;
|
||||
loading: boolean;
|
||||
@@ -19,9 +19,13 @@ interface RawJsonSettingsEditorPanelProps {
|
||||
onChange: (nextValue: string) => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
onRefresh: () => Promise<void> | 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 ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading settings.json...
|
||||
{loadingLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{ownershipNotice && <div className="mx-4 mt-4">{ownershipNotice}</div>}
|
||||
{parseWarning && (
|
||||
<div className="mx-4 mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
|
||||
Parse warning: {parseWarning}
|
||||
{parseWarningLabel}: {parseWarning}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 p-4 pt-3">
|
||||
@@ -96,7 +105,7 @@ export function RawJsonSettingsEditorPanel({
|
||||
<CodeEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
language="json"
|
||||
language={language}
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
@@ -108,3 +117,5 @@ export function RawJsonSettingsEditorPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RawJsonSettingsEditorPanel = RawConfigEditorPanel;
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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) => (
|
||||
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
|
||||
<Highlight
|
||||
prism={Prism}
|
||||
theme={isDark ? themes.nightOwl : themes.github}
|
||||
code={code}
|
||||
language={language}
|
||||
>
|
||||
{({ tokens, getLineProps, getTokenProps }) => {
|
||||
let nextValueIsSensitive = false;
|
||||
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>,
|
||||
parseError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
config: null,
|
||||
parseError: (error as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCodexDiagnostics(): Promise<CodexDashboardDiagnostics> {
|
||||
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<CodexRawConfig> {
|
||||
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<SaveCodexRawConfigResponse> {
|
||||
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<CodexRawConfig>(['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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<a
|
||||
key={`${url}-${index}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
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<string, unknown> } | { 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<string, unknown> };
|
||||
} catch (error) {
|
||||
return { valid: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={cn('text-right break-all', mono && 'font-mono text-xs')}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexPage() {
|
||||
const {
|
||||
diagnostics,
|
||||
diagnosticsLoading,
|
||||
diagnosticsError,
|
||||
refetchDiagnostics,
|
||||
rawConfig,
|
||||
rawConfigLoading,
|
||||
refetchRawConfig,
|
||||
saveRawConfigAsync,
|
||||
isSavingRawConfig,
|
||||
} = useCodex();
|
||||
|
||||
const [rawDraftText, setRawDraftText] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading Codex diagnostics...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (diagnosticsError || !diagnostics) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-destructive">
|
||||
Failed to load Codex diagnostics.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tabs defaultValue="overview" className="flex h-full flex-col">
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="routing">Runtime & Routing</TabsTrigger>
|
||||
<TabsTrigger value="docs">Docs</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden px-4 pb-4 pt-3">
|
||||
<TabsContent value="overview" className={tabContentClassName}>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 pr-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Info className="h-4 w-4" />
|
||||
How Codex works in CCS
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Codex is a first-class runtime target in CCS, but it stays runtime-only in v1.
|
||||
</p>
|
||||
<p>
|
||||
Saved default targets for API profiles and variants still remain on Claude or
|
||||
Droid.
|
||||
</p>
|
||||
<p>
|
||||
CCS-backed Codex launches can apply transient <code>-c</code> overrides and
|
||||
inject <code>CCS_CODEX_API_KEY</code>, so effective runtime values may not
|
||||
match this file exactly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
Runtime install
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
||||
{diagnostics.binary.installed ? 'Detected' : 'Not found'}
|
||||
</Badge>
|
||||
</div>
|
||||
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
|
||||
<DetailRow
|
||||
label="Binary path"
|
||||
value={diagnostics.binary.path || 'Not found'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Install directory"
|
||||
value={diagnostics.binary.installDir || 'N/A'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Version"
|
||||
value={diagnostics.binary.version || 'Unknown'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow label="Alias commands" value="ccs-codex, ccsx" mono />
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
--config override support
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
diagnostics.binary.supportsConfigOverrides ? 'default' : 'secondary'
|
||||
}
|
||||
>
|
||||
{diagnostics.binary.supportsConfigOverrides ? 'Available' : 'Missing'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Folder className="h-4 w-4" />
|
||||
Config file
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">User config</span>
|
||||
{diagnostics.file.exists ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<DetailRow label="Path" value={diagnostics.file.path} mono />
|
||||
<DetailRow label="Resolved" value={diagnostics.file.resolvedPath} mono />
|
||||
<DetailRow label="Size" value={formatBytes(diagnostics.file.sizeBytes)} />
|
||||
<DetailRow
|
||||
label="Last modified"
|
||||
value={formatTimestamp(diagnostics.file.mtimeMs)}
|
||||
/>
|
||||
{diagnostics.file.parseError && (
|
||||
<p className="text-xs text-amber-600">
|
||||
TOML warning: {diagnostics.file.parseError}
|
||||
</p>
|
||||
)}
|
||||
{diagnostics.file.readError && (
|
||||
<p className="text-xs text-destructive">
|
||||
Read warning: {diagnostics.file.readError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Current user-layer summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<DetailRow label="Model" value={diagnostics.config.model || 'Not set'} mono />
|
||||
<DetailRow
|
||||
label="Model provider"
|
||||
value={diagnostics.config.modelProvider || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Active profile"
|
||||
value={diagnostics.config.activeProfile || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Approval policy"
|
||||
value={diagnostics.config.approvalPolicy || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Sandbox mode"
|
||||
value={diagnostics.config.sandboxMode || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Web search"
|
||||
value={diagnostics.config.webSearch || 'Not set'}
|
||||
mono
|
||||
/>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<Badge variant="outline" className="justify-center">
|
||||
providers: {diagnostics.config.modelProviderCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="justify-center">
|
||||
profiles: {diagnostics.config.profileCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="justify-center">
|
||||
enabled features: {diagnostics.config.enabledFeatures.length}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="justify-center">
|
||||
MCP servers: {diagnostics.config.mcpServerCount}
|
||||
</Badge>
|
||||
</div>
|
||||
{diagnostics.config.topLevelKeys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
User-layer keys present
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{diagnostics.config.topLevelKeys.map((key) => (
|
||||
<Badge key={key} variant="secondary" className="font-mono text-[10px]">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{diagnostics.warnings.length > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
Warnings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{diagnostics.warnings.map((warning) => (
|
||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||
- {warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routing" className={tabContentClassName}>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 pr-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Route className="h-4 w-4" />
|
||||
Runtime vs provider
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<p className="font-medium">Native Codex runtime</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Use <code>ccs-codex</code>, <code>ccsx</code>, or{' '}
|
||||
<code>--target codex</code>. CCS launches the local Codex CLI and depends on
|
||||
native Codex capabilities such as <code>--config</code> overrides.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<p className="font-medium">Codex provider / bridge</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
CCS can route provider credentials transiently through CLIProxy. That is not
|
||||
the same as editing local <code>config.toml</code>, and some routed values
|
||||
may never persist here.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Supported flows</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Flow</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{diagnostics.supportMatrix.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-xs">{entry.label}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={entry.supported ? 'default' : 'secondary'}>
|
||||
{entry.supported ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{entry.notes}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Quick usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<UsageCommand label="Codex alias (explicit)" command="ccs-codex" />
|
||||
<UsageCommand label="Codex alias (short)" command="ccsx" />
|
||||
<UsageCommand
|
||||
label="Run a profile on native Codex"
|
||||
command='ccs codex --target codex "your prompt"'
|
||||
/>
|
||||
<UsageCommand
|
||||
label="Routed profile example"
|
||||
command='ccs mycodex --target codex "your prompt"'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileWarning className="h-4 w-4" />
|
||||
What this editor affects
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Edits here affect native Codex sessions first because this is the user-layer{' '}
|
||||
<code>config.toml</code>.
|
||||
</p>
|
||||
<p>
|
||||
CCS-routed Codex launches may override provider-related keys transiently and
|
||||
inject <code>CCS_CODEX_API_KEY</code>.
|
||||
</p>
|
||||
<p>
|
||||
That means the file is not a complete source of truth for every routed Codex
|
||||
launch you start from CCS.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="docs" className={tabContentClassName}>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 pr-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Upstream notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{docsReference.notes.map((note, index) => (
|
||||
<p key={`${index}-${note}`} className="text-muted-foreground">
|
||||
- {renderTextWithLinks(note)}
|
||||
</p>
|
||||
))}
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Codex docs
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{docsLinks.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{link.label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{link.description}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
||||
{link.url}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Provider / bridge reference
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{providerDocs.map((providerDoc) => (
|
||||
<a
|
||||
key={`${providerDoc.provider}-${providerDoc.url}`}
|
||||
href={providerDoc.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-md border px-2.5 py-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{providerDoc.label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
provider: {providerDoc.provider} | format: {providerDoc.apiFormat}
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/90 underline underline-offset-2">
|
||||
{providerDoc.url}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{docsReference.providerValues.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provider values: {docsReference.providerValues.join(', ')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{docsReference.settingsHierarchy.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Settings hierarchy: {docsReference.settingsHierarchy.join(' -> ')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-hidden">
|
||||
<PanelGroup direction="horizontal" className="h-full">
|
||||
<Panel defaultSize={45} minSize={35}>
|
||||
<div className="h-full border-r bg-muted/20">{renderOverview()}</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle className="group flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20">
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
|
||||
</PanelResizeHandle>
|
||||
<Panel defaultSize={55} minSize={35}>
|
||||
<RawConfigEditorPanel
|
||||
title="Codex config.toml"
|
||||
pathLabel={rawConfig?.path || diagnostics?.file.path || '$CODEX_HOME/config.toml'}
|
||||
loading={rawConfigLoading}
|
||||
parseWarning={
|
||||
rawEditorValidation.valid ? rawConfig?.parseError : rawEditorValidation.error
|
||||
}
|
||||
value={rawEditorText}
|
||||
dirty={rawConfigDirty}
|
||||
saving={isSavingRawConfig}
|
||||
saveDisabled={
|
||||
!rawConfigDirty || isSavingRawConfig || rawConfigLoading || !rawEditorValidation.valid
|
||||
}
|
||||
onChange={(next) => {
|
||||
setRawEditorDraftText(next);
|
||||
}}
|
||||
onSave={handleSaveRawConfig}
|
||||
onRefresh={refreshAll}
|
||||
language="toml"
|
||||
loadingLabel="Loading config.toml..."
|
||||
parseWarningLabel="TOML warning"
|
||||
ownershipNotice={
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300">
|
||||
<p className="font-medium">This file is upstream-owned by Codex CLI.</p>
|
||||
<p>
|
||||
CCS does not keep <code>~/.codex/config.toml</code> in sync for you.
|
||||
</p>
|
||||
<p>
|
||||
CCS-backed Codex launches may apply transient <code>-c</code> overrides and
|
||||
<code> CCS_CODEX_API_KEY</code>; those effective values may not appear here.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,4 +20,6 @@ export { ClaudeExtensionPage } from './claude-extension';
|
||||
|
||||
export { UpdatesPage } from './updates';
|
||||
|
||||
export { CodexPage } from './codex';
|
||||
|
||||
export { DroidPage } from './droid';
|
||||
|
||||
@@ -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(
|
||||
<CodeEditor
|
||||
value={'model = "gpt-5.4"\n[features]\nmulti_agent = true\n'}
|
||||
onChange={vi.fn()}
|
||||
language="toml"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('<CodexPage />');
|
||||
expect(sidebarSource).toContain("path: '/codex'");
|
||||
expect(sidebarSource).toContain("label: 'Codex CLI'");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user