feat(codex): harden runtime targeting and dashboard editing

This commit is contained in:
Tam Nhu Tran
2026-03-29 14:18:51 -04:00
parent 09b7f66c0b
commit 9e43beec40
27 changed files with 932 additions and 113 deletions
+3 -3
View File
@@ -198,15 +198,15 @@ Resolves which adapter to use via `resolveTargetType()`:
``` ```
1. --target <name> flag (highest priority) 1. --target <name> flag (highest priority)
2. Profile config: profileConfig.target field 2. argv[0] detection (runtime alias pattern):
3. argv[0] detection (runtime alias pattern):
- ccs-droid → droid - ccs-droid → droid
- ccsd → droid - ccsd → droid
- ccs-codex → codex - ccs-codex → codex
- ccsx → codex - ccsx → codex
- ccs → default - ccs → default
3. Profile config: profileConfig.target field
4. Fallback: 'claude' (lowest priority) 4. Fallback: 'claude' (lowest priority)
``` ```
+3 -3
View File
@@ -69,7 +69,7 @@ src/
│ ├── index.ts # Barrel export │ ├── index.ts # Barrel export
│ ├── target-adapter.ts # TargetAdapter interface contract │ ├── target-adapter.ts # TargetAdapter interface contract
│ ├── target-registry.ts # Registry for runtime adapter lookup │ ├── target-registry.ts # Registry for runtime adapter lookup
│ ├── target-resolver.ts # Resolution logic (flag > config > argv[0]) │ ├── target-resolver.ts # Resolution logic (flag > argv[0] > config)
│ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists │ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists
│ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations │ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations
│ ├── claude-adapter.ts # Claude Code CLI implementation │ ├── claude-adapter.ts # Claude Code CLI implementation
@@ -254,7 +254,7 @@ src/
- Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection. - Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection.
- Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback. - Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback.
- Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch. - Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch.
- Follow-up behavior: structured saves refresh the raw snapshot immediately, structured controls stay disabled while raw TOML is dirty or invalid, project trust paths must be absolute or `~/...`, and feature flags can be reset to default. - Follow-up behavior: structured saves refresh the raw snapshot immediately, refresh discards stale raw drafts, structured controls stay disabled while raw TOML is dirty/invalid/unreadable, project trust paths must be absolute or `~/...`, unsupported upstream top-level shapes are preserved instead of deleted, and feature flags can be reset to default.
- Supported Codex flows in v1: - Supported Codex flows in v1:
- `default` - `default`
- CLIProxy provider `codex` - CLIProxy provider `codex`
@@ -279,8 +279,8 @@ The targets module provides an extensible interface for dispatching profiles to
2. **Target Resolution** - Priority order: 2. **Target Resolution** - Priority order:
- `--target <cli>` flag (CLI argument) - `--target <cli>` flag (CLI argument)
- Per-profile `target` field (from config.yaml)
- `argv[0]` detection (runtime alias pattern: `ccs-droid` / `ccsd` → droid) - `argv[0]` detection (runtime alias pattern: `ccs-droid` / `ccsd` → droid)
- Per-profile `target` field (from config.yaml)
- Default: `claude` - Default: `claude`
3. **Implementations:** 3. **Implementations:**
+1 -1
View File
@@ -41,7 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes ### Recent Fixes
- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, dirty raw-editor guarding for structured controls, project-trust path validation, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. - **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file.
- **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. - **2026-03-27**: 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-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. - **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 -1
View File
@@ -58,7 +58,7 @@ CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration w
Profile Resolution (CLIProxy, Settings/API, Account-based) Profile Resolution (CLIProxy, Settings/API, Account-based)
| |
v v
Target Resolution (--target flag > config > argv[0] > default) Target Resolution (--target flag > argv[0] > config > default)
| |
v v
Get Target Adapter (Claude, Droid, or Codex) Get Target Adapter (Claude, Droid, or Codex)
+14 -14
View File
@@ -85,19 +85,19 @@ CCS resolves which adapter to use via priority-ordered checks:
└─ ccs --target droid glm └─ ccs --target droid glm
└─ ccs --target codex └─ ccs --target codex
2. Per-profile config (from ~/.ccs/config.yaml or settings.json) 2. argv[0] detection (runtime alias pattern) — binary name mapping
└─ persisted targets are currently only `claude` and `droid`
└─ profiles:
glm:
target: droid
3. argv[0] detection (runtime alias pattern) — binary name mapping
└─ ccs-droid (explicit alias) → droid └─ ccs-droid (explicit alias) → droid
└─ ccsd (legacy shortcut) → droid └─ ccsd (legacy shortcut) → droid
└─ ccs-codex (explicit alias) → codex └─ ccs-codex (explicit alias) → codex
└─ ccsx (short alias) → codex └─ ccsx (short alias) → codex
└─ ccs (regular command) → default └─ ccs (regular command) → default
3. Per-profile config (from ~/.ccs/config.yaml or settings.json)
└─ persisted targets are currently only `claude` and `droid`
└─ profiles:
glm:
target: droid
4. Fallback: 'claude' — lowest priority 4. Fallback: 'claude' — lowest priority
``` ```
@@ -117,18 +117,18 @@ export function resolveTargetType(
return parsed.targetOverride; return parsed.targetOverride;
} }
// 2. Check profile config // 2. Check argv[0] (binary name)
if (profileConfig?.target) {
// Persisted targets intentionally exclude runtime-only codex.
return profileConfig.target;
}
// 3. Check argv[0] (binary name)
const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, ''); const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, '');
if (ARGV0_TARGET_MAP[binName]) { if (ARGV0_TARGET_MAP[binName]) {
return ARGV0_TARGET_MAP[binName]; return ARGV0_TARGET_MAP[binName];
} }
// 3. Check profile config
if (profileConfig?.target) {
// Persisted targets intentionally exclude runtime-only codex.
return profileConfig.target;
}
// 4. Default to claude // 4. Default to claude
return 'claude'; return 'claude';
} }
+64 -8
View File
@@ -90,8 +90,12 @@ interface DetectedProfile {
interface RuntimeReasoningResolution { interface RuntimeReasoningResolution {
argsWithoutReasoningFlags: string[]; argsWithoutReasoningFlags: string[];
reasoningOverride: string | number | undefined; reasoningOverride: string | number | undefined;
reasoningSource: 'flag' | 'env' | undefined;
sourceDisplay: string | undefined;
} }
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
/** /**
* Smart profile detection * Smart profile detection
*/ */
@@ -122,9 +126,21 @@ function resolveRuntimeReasoningFlags(
return { return {
argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags, argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags,
reasoningOverride: runtime.reasoningOverride, reasoningOverride: runtime.reasoningOverride,
reasoningSource: runtime.sourceFlag
? 'flag'
: runtime.reasoningOverride !== undefined
? 'env'
: undefined,
sourceDisplay: runtime.sourceDisplay,
}; };
} }
function normalizeCodexRuntimeReasoningOverride(
value: string | number | undefined
): string | undefined {
return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined;
}
function exitWithRuntimeReasoningFlagError( function exitWithRuntimeReasoningFlagError(
message: string, message: string,
options: { options: {
@@ -417,6 +433,9 @@ async function main(): Promise<void> {
// Resolve non-claude target adapter once. // Resolve non-claude target adapter once.
const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null; const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null;
let resolvedSettingsPath: string | undefined;
let resolvedSettings: ReturnType<typeof loadSettings> | undefined;
let resolvedCliproxyBridge: ReturnType<typeof resolveCliproxyBridgeMetadata> | undefined;
// Preflight unsupported profile/target combinations BEFORE binary detection, // Preflight unsupported profile/target combinations BEFORE binary detection,
// so users get the most actionable error even when the target CLI is not installed. // so users get the most actionable error even when the target CLI is not installed.
@@ -426,7 +445,29 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
if (profileInfo.type !== 'settings') { if (profileInfo.type === 'settings') {
resolvedSettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: getSettingsPath(profileInfo.name);
resolvedSettings = loadSettings(resolvedSettingsPath);
resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings);
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
profileType: profileInfo.type,
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
}
process.exit(1);
}
} else {
const compatibility = evaluateTargetRuntimeCompatibility({ const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget, target: resolvedTarget,
profileType: profileInfo.type, profileType: profileInfo.type,
@@ -524,7 +565,7 @@ async function main(): Promise<void> {
} catch (error) { } catch (error) {
if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) {
exitWithRuntimeReasoningFlagError(error.message, { exitWithRuntimeReasoningFlagError(error.message, {
codexAliasLevels: 'medium|high|xhigh', codexAliasLevels: 'minimal|low|medium|high|xhigh',
includeDroidExecExample: true, includeDroidExecExample: true,
}); });
} }
@@ -534,7 +575,20 @@ async function main(): Promise<void> {
try { try {
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING); const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING);
targetRemainingArgs = runtime.argsWithoutReasoningFlags; targetRemainingArgs = runtime.argsWithoutReasoningFlags;
runtimeReasoningOverride = runtime.reasoningOverride; const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(
runtime.reasoningOverride
);
if (runtime.reasoningOverride !== undefined && !normalizedReasoning) {
if (runtime.reasoningSource === 'flag') {
throw new DroidReasoningFlagError(
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.',
'--effort'
);
}
runtimeReasoningOverride = undefined;
} else {
runtimeReasoningOverride = normalizedReasoning;
}
} catch (error) { } catch (error) {
if (error instanceof DroidReasoningFlagError) { if (error instanceof DroidReasoningFlagError) {
exitWithRuntimeReasoningFlagError(error.message, { exitWithRuntimeReasoningFlagError(error.message, {
@@ -777,11 +831,13 @@ async function main(): Promise<void> {
); );
} }
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
const expandedSettingsPath = profileInfo.settingsPath const expandedSettingsPath =
? expandPath(profileInfo.settingsPath) resolvedSettingsPath ??
: getSettingsPath(profileInfo.name); (profileInfo.settingsPath
const settings = loadSettings(expandedSettingsPath); ? expandPath(profileInfo.settingsPath)
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); : getSettingsPath(profileInfo.name));
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
if (resolvedTarget !== 'claude') { if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({ const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget, target: resolvedTarget,
+2 -2
View File
@@ -236,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'ccs <provider> --thinking <value>', 'ccs <provider> --thinking <value>',
'Set thinking budget (low/medium/high/xhigh/auto/off or number)', 'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
], ],
['ccs codex --effort <level>', 'Set codex reasoning effort (medium/high/xhigh)'], ['ccs codex --effort <level>', 'Set codex reasoning effort (minimal/low/medium/high/xhigh)'],
['ccs <provider> --1m', 'Request explicit 1M context when the selected model supports [1m]'], ['ccs <provider> --1m', 'Request explicit 1M context when the selected model supports [1m]'],
['ccs <provider> --no-1m', 'Force standard context / clear [1m]'], ['ccs <provider> --no-1m', 'Force standard context / clear [1m]'],
['ccs <provider> --logout', 'Clear authentication'], ['ccs <provider> --logout', 'Clear authentication'],
@@ -523,7 +523,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['--thinking xhigh', '32K tokens - Maximum depth'], ['--thinking xhigh', '32K tokens - Maximum depth'],
['--thinking <number>', 'Custom token budget (512-100000)'], ['--thinking <number>', 'Custom token budget (512-100000)'],
['', ''], ['', ''],
['--effort <level>', 'Codex alias for reasoning effort (medium/high/xhigh)'], ['--effort <level>', 'Codex alias for reasoning effort (minimal/low/medium/high/xhigh)'],
['--effort xhigh', 'Pin Codex effort to xhigh for this run'], ['--effort xhigh', 'Pin Codex effort to xhigh for this run'],
['', ''], ['', ''],
['Droid exec:', 'Use native Droid flag: --reasoning-effort <level>'], ['Droid exec:', 'Use native Droid flag: --reasoning-effort <level>'],
+1
View File
@@ -131,6 +131,7 @@ export interface CodexRawConfigResponse {
rawText: string; rawText: string;
config: Record<string, unknown> | null; config: Record<string, unknown> | null;
parseError: string | null; parseError: string | null;
readError: string | null;
} }
export interface CodexTopLevelSettingsPatch { export interface CodexTopLevelSettingsPatch {
+11 -4
View File
@@ -28,6 +28,13 @@ function buildConfigOverrideArgs(overrides: string[]): string[] {
return overrides.flatMap((override) => ['-c', override]); return overrides.flatMap((override) => ['-c', override]);
} }
function buildConfigOverrideSupportError(binaryInfo?: TargetBinaryInfo): Error {
const versionSummary = binaryInfo?.version ? ` (${binaryInfo.version})` : '';
return new Error(
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles or runtime reasoning overrides.`
);
}
function findDisallowedCodexManagedFlags(args: string[]): string[] { function findDisallowedCodexManagedFlags(args: string[]): string[] {
const disallowed = new Set<string>(); const disallowed = new Set<string>();
@@ -91,6 +98,9 @@ export class CodexAdapter implements TargetAdapter {
if (profileType === 'default') { if (profileType === 'default') {
if (reasoningOverride) { if (reasoningOverride) {
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
throw buildConfigOverrideSupportError(options?.binaryInfo);
}
return [ return [
...buildConfigOverrideArgs([ ...buildConfigOverrideArgs([
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`, `model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
@@ -102,10 +112,7 @@ export class CodexAdapter implements TargetAdapter {
} }
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
const versionSummary = options?.binaryInfo?.version ? ` (${options.binaryInfo.version})` : ''; throw buildConfigOverrideSupportError(options?.binaryInfo);
throw new Error(
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles.`
);
} }
if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) { if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) {
@@ -70,8 +70,8 @@ const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high
const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']); const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']);
const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']); const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']); const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']);
const PERSONALITY_VALUES = new Set(['default', 'pragmatic', 'concise', 'direct']); const PERSONALITY_VALUES = new Set(['none', 'friendly', 'pragmatic']);
const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'ask']); const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'untrusted']);
function isObject(value: unknown): value is Record<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -113,9 +113,20 @@ function deleteIfEmpty(target: Record<string, unknown>, key: string) {
} }
} }
function shouldPreserveUnsupportedValue(value: unknown): boolean {
return Array.isArray(value) || isObject(value);
}
function deleteFieldUnlessUnsupported(target: Record<string, unknown>, key: string) {
if (shouldPreserveUnsupportedValue(target[key])) {
return;
}
delete target[key];
}
function setStringField(target: Record<string, unknown>, key: string, value: unknown) { function setStringField(target: Record<string, unknown>, key: string, value: unknown) {
if (!isNonEmptyString(value)) { if (!isNonEmptyString(value)) {
delete target[key]; deleteFieldUnlessUnsupported(target, key);
return; return;
} }
target[key] = value.trim(); target[key] = value.trim();
@@ -129,7 +140,7 @@ function setEnumStringField(
label: string label: string
) { ) {
if (!isNonEmptyString(value)) { if (!isNonEmptyString(value)) {
delete target[key]; deleteFieldUnlessUnsupported(target, key);
return; return;
} }
@@ -145,7 +156,7 @@ function setEnumStringField(
function setBooleanField(target: Record<string, unknown>, key: string, value: unknown) { function setBooleanField(target: Record<string, unknown>, key: string, value: unknown) {
if (typeof value !== 'boolean') { if (typeof value !== 'boolean') {
delete target[key]; deleteFieldUnlessUnsupported(target, key);
return; return;
} }
target[key] = value; target[key] = value;
@@ -158,7 +169,7 @@ function setNumberField(
options: { integer?: boolean; min?: number } = {} options: { integer?: boolean; min?: number } = {}
) { ) {
if (typeof value !== 'number' || !Number.isFinite(value)) { if (typeof value !== 'number' || !Number.isFinite(value)) {
delete target[key]; deleteFieldUnlessUnsupported(target, key);
return; return;
} }
@@ -199,6 +210,24 @@ function assertPatchableToml(fileProbe: {
return asObject(fileProbe.config) ?? {}; return asObject(fileProbe.config) ?? {};
} }
function summarizeApprovalPolicy(value: unknown): string | null {
const stringValue = asString(value);
if (stringValue) {
return stringValue;
}
const objectValue = asObject(value);
if (!objectValue) {
return null;
}
if (hasOwn(objectValue, 'granular')) {
return 'granular (custom)';
}
return 'custom object';
}
function applyTopLevelSettingsPatch( function applyTopLevelSettingsPatch(
target: Record<string, unknown>, target: Record<string, unknown>,
values: Extract<CodexConfigPatchInput, { kind: 'top-level' }>['values'] values: Extract<CodexConfigPatchInput, { kind: 'top-level' }>['values']
@@ -460,16 +489,11 @@ function applyMcpServerPatch(
if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled); if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled);
if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required); if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required);
if (hasOwn(values, 'startupTimeoutSec')) { if (hasOwn(values, 'startupTimeoutSec')) {
setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, { delete nextServer.startup_timeout_ms;
integer: true, setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, { min: 1 });
min: 1,
});
} }
if (hasOwn(values, 'toolTimeoutSec')) { if (hasOwn(values, 'toolTimeoutSec')) {
setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, { setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, { min: 1 });
integer: true,
min: 1,
});
} }
if (hasOwn(values, 'enabledTools')) { if (hasOwn(values, 'enabledTools')) {
const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools'); const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools');
@@ -506,7 +530,9 @@ export function resolveCodexConfigPaths(
): CodexConfigPaths { ): CodexConfigPaths {
const env = options.env ?? process.env; const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir(); const homeDir = options.homeDir ?? os.homedir();
const baseDir = env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex'); const baseDir = path.resolve(
env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex')
);
const baseDirDisplay = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex'; const baseDirDisplay = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex';
return { return {
@@ -596,7 +622,8 @@ export function summarizeCodexMcpServers(value: unknown): CodexMcpServerDiagnost
const startupTimeoutMs = asNumber(server.startup_timeout_ms); const startupTimeoutMs = asNumber(server.startup_timeout_ms);
const startupTimeoutSec = const startupTimeoutSec =
asNumber(server.startup_timeout_sec) ?? (startupTimeoutMs ? startupTimeoutMs / 1000 : null); asNumber(server.startup_timeout_sec) ??
(startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null);
return { return {
name, name,
@@ -727,7 +754,7 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
modelReasoningEffort: asString(config?.model_reasoning_effort), modelReasoningEffort: asString(config?.model_reasoning_effort),
modelProvider: asString(config?.model_provider), modelProvider: asString(config?.model_provider),
activeProfile, activeProfile,
approvalPolicy: asString(config?.approval_policy), approvalPolicy: summarizeApprovalPolicy(config?.approval_policy),
sandboxMode: asString(config?.sandbox_mode), sandboxMode: asString(config?.sandbox_mode),
webSearch: asString(config?.web_search), webSearch: asString(config?.web_search),
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit), toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
@@ -768,6 +795,7 @@ export async function getCodexRawConfig(): Promise<CodexRawConfigResponse> {
rawText: fileProbe.rawText, rawText: fileProbe.rawText,
config: fileProbe.config, config: fileProbe.config,
parseError: fileProbe.diagnostics.parseError, parseError: fileProbe.diagnostics.parseError,
readError: fileProbe.diagnostics.readError,
}; };
} }
@@ -827,7 +855,7 @@ export async function patchCodexConfig(
const saved = await writeTomlFileAtomic({ const saved = await writeTomlFileAtomic({
filePath: paths.configPath, filePath: paths.configPath,
rawText, rawText,
expectedMtime: input.expectedMtime, expectedMtime: input.expectedMtime ?? fileProbe.diagnostics.mtimeMs ?? undefined,
fileLabel: 'config.toml', fileLabel: 'config.toml',
}); });
@@ -840,5 +868,6 @@ export async function patchCodexConfig(
rawText, rawText,
config: nextConfig, config: nextConfig,
parseError: null, parseError: null,
readError: null,
}; };
} }
+48
View File
@@ -30,11 +30,36 @@ describe('CodexAdapter', () => {
apiKey: '', apiKey: '',
reasoningOverride: 'medium', reasoningOverride: 'medium',
}, },
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
}); });
expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']); expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']);
}); });
test('rejects default-mode reasoning overrides when codex lacks config override support', () => {
expect(() =>
adapter.buildArgs('default', ['--search'], {
profileType: 'default',
creds: {
profile: 'default',
baseUrl: '',
apiKey: '',
reasoningOverride: 'high',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
version: 'codex-cli 0.1.0',
features: [],
},
})
).toThrow(/does not advertise --config overrides/);
});
test('injects transient config overrides for CCS-backed launches', () => { test('injects transient config overrides for CCS-backed launches', () => {
const args = adapter.buildArgs('codex', ['--search'], { const args = adapter.buildArgs('codex', ['--search'], {
profileType: 'cliproxy', profileType: 'cliproxy',
@@ -97,6 +122,29 @@ describe('CodexAdapter', () => {
).toThrow(/does not allow --profile\/-p/); ).toThrow(/does not allow --profile\/-p/);
}); });
test('rejects user-supplied --config overrides for CCS-backed launches', () => {
const options = {
profileType: 'cliproxy' as const,
creds: {
profile: 'codex',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
apiKey: 'cliproxy-token',
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
};
expect(() => adapter.buildArgs('codex', ['-c', 'model="other"', '--search'], options)).toThrow(
/does not allow --config\/-c/
);
expect(() =>
adapter.buildArgs('codex', ['--config=model="other"', '--search'], options)
).toThrow(/does not allow --config\/-c/);
});
test('rejects unsupported reasoning override values for CCS-backed launches', () => { test('rejects unsupported reasoning override values for CCS-backed launches', () => {
expect(() => expect(() =>
adapter.buildArgs('codex', ['--search'], { adapter.buildArgs('codex', ['--search'], {
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
encoding: 'utf8',
env,
timeout: 20000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
function readLoggedCodexCalls(logPath: string): string[][] {
if (!fs.existsSync(logPath)) {
return [];
}
return fs
.readFileSync(logPath, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]);
}
describe('codex runtime integration', () => {
let tmpHome: string;
let ccsDir: string;
let fakeCodexPath: string;
let codexArgsLogPath: string;
let emptyPathDir: string;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-route-it-'));
ccsDir = path.join(tmpHome, '.ccs');
fakeCodexPath = path.join(tmpHome, 'fake-codex.js');
codexArgsLogPath = path.join(tmpHome, 'codex-args.log');
emptyPathDir = path.join(tmpHome, 'empty-bin');
fs.mkdirSync(ccsDir, { recursive: true });
fs.mkdirSync(emptyPathDir, { recursive: true });
fs.writeFileSync(
fakeCodexPath,
`#!/usr/bin/env node
const fs = require('fs');
const out = process.env.CCS_TEST_CODEX_ARGS_OUT;
if (out) {
fs.appendFileSync(out, JSON.stringify(process.argv.slice(2)) + '\\n');
}
if (process.argv[2] === '--version') {
process.stdout.write(process.env.CCS_TEST_CODEX_VERSION || 'codex-cli 0.118.0-alpha.3');
process.exit(0);
}
if (process.argv[2] === '--help') {
process.stdout.write(
process.env.CCS_TEST_CODEX_HELP ||
' -c, --config <key=value>\\n -p, --profile <CONFIG_PROFILE>\\n'
);
process.exit(0);
}
process.exit(0);
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeCodexPath, 0o755);
});
afterEach(() => {
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('ignores numeric CCS_THINKING env overrides for native Codex default mode', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_THINKING: '8192',
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls.at(-1)).toEqual(['fix failing tests']);
});
it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_THINKING: 'off',
});
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls.at(-1)).toEqual(['fix failing tests']);
});
it('fails fast when native Codex reasoning overrides need unsupported --config support', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
CCS_TEST_CODEX_HELP: ' -p, --profile <CONFIG_PROFILE>\\n',
});
expect(result.status).toBe(1);
expect(result.stderr).toContain('does not advertise --config overrides');
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['--version'], ['--help']]);
});
it('reports unsupported generic settings profiles before Codex install guidance', () => {
if (process.platform === 'win32') return;
const settingsPath = path.join(ccsDir, 'myglm.settings.json');
const configPath = path.join(ccsDir, 'config.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://example.invalid/anthropic',
ANTHROPIC_AUTH_TOKEN: 'test-token',
ANTHROPIC_MODEL: 'gpt-5.4',
},
},
null,
2
)
);
fs.writeFileSync(
configPath,
JSON.stringify(
{
profiles: {
myglm: settingsPath,
},
},
null,
2
)
);
const result = runCcs(['myglm', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
PATH: emptyPathDir,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain(
'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.'
);
expect(result.stderr).not.toContain('Install a recent @openai/codex build');
});
});
@@ -62,14 +62,14 @@ describe('codex-dashboard-service', () => {
it('resolves codex config paths with CODEX_HOME override', () => { it('resolves codex config paths with CODEX_HOME override', () => {
const resolved = resolveCodexConfigPaths({ const resolved = resolveCodexConfigPaths({
env: { env: {
CODEX_HOME: '/tmp/custom-codex-home', CODEX_HOME: './custom-codex-home',
} as NodeJS.ProcessEnv, } as NodeJS.ProcessEnv,
homeDir: '/Users/tester', homeDir: '/Users/tester',
}); });
expect(resolved.baseDir).toBe('/tmp/custom-codex-home'); expect(resolved.baseDir).toBe(path.resolve('./custom-codex-home'));
expect(resolved.baseDirDisplay).toBe('$CODEX_HOME'); expect(resolved.baseDirDisplay).toBe('$CODEX_HOME');
expect(resolved.configPath).toBe('/tmp/custom-codex-home/config.toml'); expect(resolved.configPath).toBe(path.join(path.resolve('./custom-codex-home'), 'config.toml'));
expect(resolved.configDisplayPath).toBe('$CODEX_HOME/config.toml'); expect(resolved.configDisplayPath).toBe('$CODEX_HOME/config.toml');
}); });
@@ -103,7 +103,7 @@ describe('codex-dashboard-service', () => {
}); });
const projects = summarizeCodexProjectTrust({ const projects = summarizeCodexProjectTrust({
'/tmp/a': { trust_level: 'trusted' }, '/tmp/a': { trust_level: 'trusted' },
'/tmp/b': { trust_level: 'ask' }, '/tmp/b': { trust_level: 'untrusted' },
}); });
const servers = summarizeCodexMcpServers({ const servers = summarizeCodexMcpServers({
stdio: { stdio: {
@@ -133,6 +133,7 @@ describe('codex-dashboard-service', () => {
expect(raw.path).toBe('$CODEX_HOME/config.toml'); expect(raw.path).toBe('$CODEX_HOME/config.toml');
expect(raw.rawText).toBe(''); expect(raw.rawText).toBe('');
expect(raw.config).toBeNull(); expect(raw.config).toBeNull();
expect(raw.readError).toBeNull();
}); });
it('returns parseError when config.toml is invalid TOML', async () => { it('returns parseError when config.toml is invalid TOML', async () => {
@@ -145,6 +146,19 @@ describe('codex-dashboard-service', () => {
expect(raw.config).toBeNull(); expect(raw.config).toBeNull();
}); });
it('returns readError when config.toml is a symlink', async () => {
const configPath = path.join(codexHome, 'config.toml');
const targetPath = path.join(testRoot, 'linked.toml');
fs.writeFileSync(targetPath, 'model = "gpt-5.4"\n');
fs.symlinkSync(targetPath, configPath);
const raw = await getCodexRawConfig();
expect(raw.exists).toBe(true);
expect(raw.readError).toContain('Refusing symlink file');
expect(raw.config).toBeNull();
});
it('includes docs links, support matrix, and config summaries in diagnostics', async () => { it('includes docs links, support matrix, and config summaries in diagnostics', async () => {
fs.writeFileSync( fs.writeFileSync(
path.join(codexHome, 'config.toml'), path.join(codexHome, 'config.toml'),
@@ -170,7 +184,7 @@ wire_api = "responses"
trust_level = "trusted" trust_level = "trusted"
[projects."/tmp/project-b"] [projects."/tmp/project-b"]
trust_level = "ask" trust_level = "untrusted"
[mcp_servers.playwright] [mcp_servers.playwright]
command = "npx" command = "npx"
@@ -204,6 +218,17 @@ model = "gpt-5.4"
expect(diagnostics.supportMatrix.some((entry) => entry.id === 'default')).toBe(true); expect(diagnostics.supportMatrix.some((entry) => entry.id === 'default')).toBe(true);
}); });
it('summarizes granular approval policies without flattening them to null', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
'approval_policy = { granular = { edit = "on-request" } }\n'
);
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.config.approvalPolicy).toBe('granular (custom)');
});
it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => { it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => {
writeCodexStub({ helpText: ' -p, --profile <CONFIG_PROFILE>\n' }); writeCodexStub({ helpText: ' -p, --profile <CONFIG_PROFILE>\n' });
fs.writeFileSync( fs.writeFileSync(
@@ -289,7 +314,7 @@ bearer_token = "secret"
sandboxMode: 'workspace-write', sandboxMode: 'workspace-write',
webSearch: 'cached', webSearch: 'cached',
toolOutputTokenLimit: 12000, toolOutputTokenLimit: 12000,
personality: 'pragmatic', personality: 'friendly',
}, },
}); });
@@ -304,12 +329,51 @@ bearer_token = "secret"
expect(diagnostics.config.model).toBe('gpt-5.4'); expect(diagnostics.config.model).toBe('gpt-5.4');
expect(diagnostics.config.modelReasoningEffort).toBe('high'); expect(diagnostics.config.modelReasoningEffort).toBe('high');
expect(diagnostics.config.toolOutputTokenLimit).toBe(12000); expect(diagnostics.config.toolOutputTokenLimit).toBe(12000);
expect(diagnostics.config.personality).toBe('pragmatic'); expect(diagnostics.config.personality).toBe('friendly');
expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a'); expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a');
expect(result.rawText).toContain('model = "gpt-5.4"'); expect(result.rawText).toContain('model = "gpt-5.4"');
expect(result.config?.model).toBe('gpt-5.4'); expect(result.config?.model).toBe('gpt-5.4');
}); });
it('allows structured patches on existing config.toml even when expectedMtime is omitted', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
const result = await patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
});
expect(result.rawText).toContain('model = "gpt-5.4"');
expect(result.rawText).toContain('[features]');
expect(result.rawText).toContain('multi_agent = true');
expect(result.config?.features).toEqual({ multi_agent: true });
});
it('preserves unsupported approval_policy objects when structured saves touch other fields', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
'model = "gpt-5.4"\napproval_policy = { granular = { edit = "on-request" } }\n'
);
const current = await getCodexRawConfig();
const result = await patchCodexConfig({
kind: 'top-level',
expectedMtime: current.mtime,
values: {
model: 'gpt-5.4-mini',
approvalPolicy: null,
},
});
expect(result.rawText).toContain('model = "gpt-5.4-mini"');
expect(result.rawText).toContain('[approval_policy.granular]');
expect(result.rawText).toContain('edit = "on-request"');
expect(result.config?.approval_policy).toEqual({
granular: { edit: 'on-request' },
});
});
it('expands home paths for project trust and rejects relative paths', async () => { it('expands home paths for project trust and rejects relative paths', async () => {
const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace'); const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace');
const expanded = await patchCodexConfig({ const expanded = await patchCodexConfig({
@@ -384,6 +448,46 @@ bearer_token = "secret"
expect(profileResult.config?.profile).toBe('deep-review'); expect(profileResult.config?.profile).toBe('deep-review');
}); });
it('accepts non-integer MCP timeout values documented by upstream Codex', async () => {
const result = await patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'streaming',
values: {
transport: 'stdio',
command: 'npx',
startupTimeoutSec: 1.5,
toolTimeoutSec: 2.25,
},
});
expect(result.rawText).toContain('startup_timeout_sec = 1.5');
expect(result.rawText).toContain('tool_timeout_sec = 2.25');
});
it('rewrites legacy startup_timeout_ms keys when editing MCP server timeouts', async () => {
fs.writeFileSync(
path.join(codexHome, 'config.toml'),
['[mcp_servers.streaming]', 'command = "npx"', 'startup_timeout_ms = 1500', ''].join('\n')
);
const raw = await getCodexRawConfig();
const result = await patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'streaming',
expectedMtime: raw.mtime,
values: {
transport: 'stdio',
command: 'npx',
startupTimeoutSec: 2.5,
},
});
expect(result.rawText).toContain('startup_timeout_sec = 2.5');
expect(result.rawText).not.toContain('startup_timeout_ms');
});
it('patches streamable-http mcp servers through structured controls', async () => { it('patches streamable-http mcp servers through structured controls', async () => {
const result = await patchCodexConfig({ const result = await patchCodexConfig({
kind: 'mcp-server', kind: 'mcp-server',
@@ -76,12 +76,14 @@ describe('codex routes', () => {
rawText: string; rawText: string;
config: Record<string, unknown> | null; config: Record<string, unknown> | null;
parseError: string | null; parseError: string | null;
readError: string | null;
}; };
expect(json.success).toBe(true); expect(json.success).toBe(true);
expect(json.exists).toBe(true); expect(json.exists).toBe(true);
expect(json.mtime).toBeGreaterThan(0); expect(json.mtime).toBeGreaterThan(0);
expect(json.parseError).toBeNull(); expect(json.parseError).toBeNull();
expect(json.readError).toBeNull();
expect(json.rawText).toContain('model = "gpt-5.4"'); expect(json.rawText).toContain('model = "gpt-5.4"');
expect(json.rawText).toContain('sandbox_mode = "workspace-write"'); expect(json.rawText).toContain('sandbox_mode = "workspace-write"');
expect(json.config?.model).toBe('gpt-5.4'); expect(json.config?.model).toBe('gpt-5.4');
@@ -112,6 +114,32 @@ describe('codex routes', () => {
expect(json.mtime).toBeGreaterThan(0); expect(json.mtime).toBeGreaterThan(0);
}); });
it('allows PATCH /config/patch on an existing config.toml without expectedMtime', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
}),
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
success: boolean;
rawText: string;
config: Record<string, unknown> | null;
};
expect(json.success).toBe(true);
expect(json.rawText).toContain('multi_agent = true');
expect(json.config?.features).toEqual({ multi_agent: true });
});
it('returns 400 when PATCH /config/patch omits kind', async () => { it('returns 400 when PATCH /config/patch omits kind', async () => {
const res = await fetch(`${baseUrl}/api/codex/config/patch`, { const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH', method: 'PATCH',
@@ -51,7 +51,7 @@ function ProjectTrustComposer({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="trusted">trusted</SelectItem> <SelectItem value="trusted">trusted</SelectItem>
<SelectItem value="ask">ask</SelectItem> <SelectItem value="untrusted">untrusted</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}> <Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
@@ -117,7 +117,7 @@ export function CodexProjectTrustCard({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onClick={() =>
onSave(entry.path, entry.trustLevel === 'trusted' ? 'ask' : 'trusted') onSave(entry.path, entry.trustLevel === 'trusted' ? 'untrusted' : 'trusted')
} }
disabled={disabled || saving} disabled={disabled || saving}
> >
@@ -32,6 +32,32 @@ function withCurrentValue(options: string[], current: string | null | undefined)
return current && !options.includes(current) ? [current, ...options] : options; return current && !options.includes(current) ? [current, ...options] : options;
} }
function buildTopLevelPatch(
initialValues: CodexTopLevelSettingsView,
draft: CodexTopLevelSettingsView
): CodexTopLevelSettingsPatch {
const patch: CodexTopLevelSettingsPatch = {};
if (draft.model !== initialValues.model) patch.model = draft.model;
if (draft.modelReasoningEffort !== initialValues.modelReasoningEffort) {
patch.modelReasoningEffort = draft.modelReasoningEffort;
}
if (draft.modelProvider !== initialValues.modelProvider) {
patch.modelProvider = draft.modelProvider;
}
if (draft.approvalPolicy !== initialValues.approvalPolicy) {
patch.approvalPolicy = draft.approvalPolicy;
}
if (draft.sandboxMode !== initialValues.sandboxMode) patch.sandboxMode = draft.sandboxMode;
if (draft.webSearch !== initialValues.webSearch) patch.webSearch = draft.webSearch;
if (draft.toolOutputTokenLimit !== initialValues.toolOutputTokenLimit) {
patch.toolOutputTokenLimit = draft.toolOutputTokenLimit;
}
if (draft.personality !== initialValues.personality) patch.personality = draft.personality;
return patch;
}
interface TopLevelControlsFormProps { interface TopLevelControlsFormProps {
initialValues: CodexTopLevelSettingsView; initialValues: CodexTopLevelSettingsView;
providerNames: string[]; providerNames: string[];
@@ -62,10 +88,9 @@ function TopLevelControlsForm({
draft.sandboxMode draft.sandboxMode
); );
const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch); const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch);
const personalityOptions = withCurrentValue( const personalityOptions = withCurrentValue(['none', 'friendly', 'pragmatic'], draft.personality);
['default', 'pragmatic', 'concise', 'direct'], const patch = buildTopLevelPatch(initialValues, draft);
draft.personality const hasChanges = Object.keys(patch).length > 0;
);
return ( return (
<> <>
@@ -242,7 +267,7 @@ function TopLevelControlsForm({
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={() => onSave(draft)} disabled={disabled || saving}> <Button onClick={() => onSave(patch)} disabled={disabled || saving || !hasChanges}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save top-level settings Save top-level settings
</Button> </Button>
@@ -264,7 +289,7 @@ export function CodexTopLevelControlsCard({
title="Top-level controls" title="Top-level controls"
badge="config.toml" badge="config.toml"
icon={<SlidersHorizontal className="h-4 w-4" />} icon={<SlidersHorizontal className="h-4 w-4" />}
description="Structured controls for the stable top-level Codex settings users touch most often." description="Structured controls for the stable top-level Codex settings users touch most often. Unsupported upstream shapes stay untouched and should be edited in raw TOML."
disabledReason={disabledReason} disabledReason={disabledReason}
> >
<TopLevelControlsForm <TopLevelControlsForm
@@ -12,13 +12,16 @@ interface RawConfigEditorPanelProps {
pathLabel: string; pathLabel: string;
loading: boolean; loading: boolean;
parseWarning: string | null | undefined; parseWarning: string | null | undefined;
readWarning?: string | null | undefined;
value: string; value: string;
dirty: boolean; dirty: boolean;
readOnly?: boolean;
saving: boolean; saving: boolean;
saveDisabled: boolean; saveDisabled: boolean;
onChange: (nextValue: string) => void; onChange: (nextValue: string) => void;
onSave: () => Promise<void> | void; onSave: () => Promise<void> | void;
onRefresh: () => Promise<void> | void; onRefresh: () => Promise<void> | void;
onDiscard?: () => void;
language?: 'json' | 'yaml' | 'toml'; language?: 'json' | 'yaml' | 'toml';
loadingLabel?: string; loadingLabel?: string;
parseWarningLabel?: string; parseWarningLabel?: string;
@@ -30,13 +33,16 @@ export function RawConfigEditorPanel({
pathLabel, pathLabel,
loading, loading,
parseWarning, parseWarning,
readWarning,
value, value,
dirty, dirty,
readOnly = false,
saving, saving,
saveDisabled, saveDisabled,
onChange, onChange,
onSave, onSave,
onRefresh, onRefresh,
onDiscard,
language = 'json', language = 'json',
loadingLabel = 'Loading settings.json...', loadingLabel = 'Loading settings.json...',
parseWarningLabel = 'Parse warning', parseWarningLabel = 'Parse warning',
@@ -76,11 +82,16 @@ export function RawConfigEditorPanel({
)} )}
Save Save
</Button> </Button>
{onDiscard ? (
<Button variant="outline" size="sm" onClick={onDiscard} disabled={!dirty || loading}>
Discard
</Button>
) : null}
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}> <Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}>
<Copy className="h-4 w-4 mr-1" /> <Copy className="h-4 w-4 mr-1" />
{copied ? 'Copied' : 'Copy'} {copied ? 'Copied' : 'Copy'}
</Button> </Button>
<Button variant="outline" size="sm" onClick={onRefresh}> <Button variant="outline" size="sm" onClick={onRefresh} aria-label="Refresh raw config">
<RefreshCw className={cn('h-4 w-4', loading ? 'animate-spin' : '')} /> <RefreshCw className={cn('h-4 w-4', loading ? 'animate-spin' : '')} />
</Button> </Button>
</div> </div>
@@ -100,12 +111,18 @@ export function RawConfigEditorPanel({
{parseWarningLabel}: {parseWarning} {parseWarningLabel}: {parseWarning}
</div> </div>
)} )}
{readWarning && (
<div className="mx-4 mt-4 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Read-only: {readWarning}
</div>
)}
<div className="min-h-0 flex-1 p-4 pt-3"> <div className="min-h-0 flex-1 p-4 pt-3">
<div className="h-full rounded-md border overflow-hidden bg-background"> <div className="h-full rounded-md border overflow-hidden bg-background">
<CodeEditor <CodeEditor
value={value} value={value}
onChange={onChange} onChange={onChange}
language={language} language={language}
readonly={readOnly}
minHeight="100%" minHeight="100%"
heightMode="fill-parent" heightMode="fill-parent"
/> />
+1
View File
@@ -97,6 +97,7 @@ export function useCodex() {
rawText: variables.rawText, rawText: variables.rawText,
config: parsed.config, config: parsed.config,
parseError: parsed.parseError, parseError: parsed.parseError,
readError: null,
}; };
}); });
queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] }); queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] });
+4 -16
View File
@@ -1,6 +1,10 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ApiConflictError, withApiBase } from '@/lib/api-client'; import { ApiConflictError, withApiBase } from '@/lib/api-client';
import type {
CompatibleCliDocLink,
CompatibleCliProviderDocLink,
} from '@shared/compatible-cli-contracts';
export interface DroidBinaryDiagnostics { export interface DroidBinaryDiagnostics {
installed: boolean; installed: boolean;
@@ -36,22 +40,6 @@ export interface DroidCustomModelDiagnostics {
apiKeyPreview: string | null; apiKeyPreview: string | null;
} }
export interface CompatibleCliDocLink {
id: string;
label: string;
url: string;
category: 'overview' | 'configuration' | 'byok' | 'reference';
source: 'factory' | 'provider';
description: string;
}
export interface CompatibleCliProviderDocLink {
provider: string;
label: string;
apiFormat: string;
url: string;
}
export interface DroidDashboardDiagnostics { export interface DroidDashboardDiagnostics {
binary: DroidBinaryDiagnostics; binary: DroidBinaryDiagnostics;
files: { files: {
+4 -1
View File
@@ -184,6 +184,7 @@ export function readCodexMcpServers(config: Record<string, unknown> | null): Cod
const server = asObject(value); const server = asObject(value);
if (!server) return null; if (!server) return null;
const transport = asString(server.command) ? 'stdio' : 'streamable-http'; const transport = asString(server.command) ? 'stdio' : 'streamable-http';
const startupTimeoutMs = asNumber(server.startup_timeout_ms);
return { return {
name, name,
transport, transport,
@@ -192,7 +193,9 @@ export function readCodexMcpServers(config: Record<string, unknown> | null): Cod
url: asString(server.url), url: asString(server.url),
enabled: server.enabled !== false, enabled: server.enabled !== false,
required: server.required === true, required: server.required === true,
startupTimeoutSec: asNumber(server.startup_timeout_sec), startupTimeoutSec:
asNumber(server.startup_timeout_sec) ??
(startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null),
toolTimeoutSec: asNumber(server.tool_timeout_sec), toolTimeoutSec: asNumber(server.tool_timeout_sec),
enabledTools: asStringArray(server.enabled_tools), enabledTools: asStringArray(server.enabled_tools),
disabledTools: asStringArray(server.disabled_tools), disabledTools: asStringArray(server.disabled_tools),
+4 -4
View File
@@ -839,7 +839,7 @@ const resources = {
supportLine1Suffix: '(token-based)', supportLine1Suffix: '(token-based)',
supportLine2Prefix: 'Reasoning effort:', supportLine2Prefix: 'Reasoning effort:',
supportLine2SuffixPrefix: '(suffix or ', supportLine2SuffixPrefix: '(suffix or ',
supportLine2SuffixPostfix: ': medium/high/xhigh)', supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
supportLine3Prefix: 'Codex suffixes pin effort (for example ', supportLine3Prefix: 'Codex suffixes pin effort (for example ',
supportLine3Suffix: '); unsuffixed models use Thinking mode.', supportLine3Suffix: '); unsuffixed models use Thinking mode.',
}, },
@@ -2012,7 +2012,7 @@ const resources = {
supportLine1Suffix: '(基于 token', supportLine1Suffix: '(基于 token',
supportLine2Prefix: '推理强度:', supportLine2Prefix: '推理强度:',
supportLine2SuffixPrefix: '(后缀或 ', supportLine2SuffixPrefix: '(后缀或 ',
supportLine2SuffixPostfix: 'medium/high/xhigh', supportLine2SuffixPostfix: 'minimal/low/medium/high/xhigh',
supportLine3Prefix: 'Codex 后缀会固定强度(例如 ', supportLine3Prefix: 'Codex 后缀会固定强度(例如 ',
supportLine3Suffix: ');无后缀模型使用 Thinking mode。', supportLine3Suffix: ');无后缀模型使用 Thinking mode。',
}, },
@@ -3232,7 +3232,7 @@ const resources = {
supportLine1Suffix: '(dựa trên token)', supportLine1Suffix: '(dựa trên token)',
supportLine2Prefix: 'Nỗ lực lý luận:', supportLine2Prefix: 'Nỗ lực lý luận:',
supportLine2SuffixPrefix: '(hậu tố hoặc ', supportLine2SuffixPrefix: '(hậu tố hoặc ',
supportLine2SuffixPostfix: ': medium/high/xhigh)', supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
supportLine3Prefix: 'Hậu tố Codex cố định mức effort (ví dụ ', supportLine3Prefix: 'Hậu tố Codex cố định mức effort (ví dụ ',
supportLine3Suffix: '); model không hậu tố sẽ dùng chế độ Thinking.', supportLine3Suffix: '); model không hậu tố sẽ dùng chế độ Thinking.',
}, },
@@ -4469,7 +4469,7 @@ const resources = {
supportLine1Suffix: '(トークンベース)', supportLine1Suffix: '(トークンベース)',
supportLine2Prefix: '推論強度:', supportLine2Prefix: '推論強度:',
supportLine2SuffixPrefix: '(サフィックス、または ', supportLine2SuffixPrefix: '(サフィックス、または ',
supportLine2SuffixPostfix: ': medium/high/xhigh)', supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
supportLine3Prefix: 'Codex のサフィックスは推論強度を固定します(例: ', supportLine3Prefix: 'Codex のサフィックスは推論強度を固定します(例: ',
supportLine3Suffix: ')。サフィックスなしのモデルは思考モードを使います。', supportLine3Suffix: ')。サフィックスなしのモデルは思考モードを使います。',
}, },
+6 -12
View File
@@ -87,17 +87,14 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
command: 'ccs codex --target codex "your prompt"', command: 'ccs codex --target codex "your prompt"',
}, },
{ {
id: 'open-cliproxy-codex', id: 'open-codex-dashboard',
label: 'Open Codex provider settings', label: 'Open Codex dashboard',
description: 'Review Codex provider and bridge flows in the dashboard.', description: 'Review Codex runtime support, config layers, and dashboard setup flows.',
type: 'route', type: 'route',
path: '/cliproxy', path: '/codex',
}, },
], ],
routes: [ routes: [{ label: 'Codex CLI', path: '/codex' }],
{ label: 'CLIProxy', path: '/cliproxy' },
{ label: 'API Profiles', path: '/providers' },
],
commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex "your prompt"'], commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex "your prompt"'],
}, },
{ {
@@ -243,10 +240,7 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
auth: 'Native Codex auth for default mode, env_key injection for CCS-backed routes', auth: 'Native Codex auth for default mode, env_key injection for CCS-backed routes',
model: 'Native Codex config or routed Codex model mapping from CLIProxy', model: 'Native Codex config or routed Codex model mapping from CLIProxy',
}, },
routes: [ routes: [{ label: 'Codex CLI', path: '/codex' }],
{ label: 'CLIProxy', path: '/cliproxy' },
{ label: 'API Profiles', path: '/providers' },
],
commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'], commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'],
notes: notes:
'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.', 'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.',
+37 -10
View File
@@ -46,16 +46,22 @@ export function CodexPage() {
: { valid: true as const }; : { valid: true as const };
const controlsConfig = rawConfig?.config ?? null; const controlsConfig = rawConfig?.config ?? null;
const structuredControlsDisabled = const structuredControlsDisabled =
rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null; rawConfigLoading ||
!rawConfig ||
rawConfigDirty ||
rawConfig?.parseError !== null ||
rawConfig?.readError !== null;
const controlsDisabledReason = rawConfigError const controlsDisabledReason = rawConfigError
? 'Structured controls unavailable: failed to load the current config.toml.' ? 'Structured controls unavailable: failed to load the current config.toml.'
: rawConfigDirty : rawConfig?.readError
? rawEditorValidation.valid ? `Structured controls unavailable: ${rawConfig.readError}`
? 'Save or discard raw TOML edits before using structured controls.' : rawConfigDirty
: 'Fix or discard raw TOML edits before using structured controls.' ? rawEditorValidation.valid
: rawConfig?.parseError ? 'Save or discard raw TOML edits before using structured controls.'
? `Structured controls disabled: ${rawConfig.parseError}` : 'Fix or discard raw TOML edits before using structured controls.'
: null; : rawConfig?.parseError
? `Structured controls disabled: ${rawConfig.parseError}`
: null;
const topLevelSettings = useMemo( const topLevelSettings = useMemo(
() => readCodexTopLevelSettings(controlsConfig), () => readCodexTopLevelSettings(controlsConfig),
@@ -82,7 +88,21 @@ export function CodexPage() {
}; };
const refreshAll = async () => { const refreshAll = async () => {
await Promise.all([refetchDiagnostics(), refetchRawConfig()]); try {
const results = await Promise.all([refetchDiagnostics(), refetchRawConfig()]);
const refreshFailed = results.some(
(result) => !result || result.status === 'error' || result.isError || result.error
);
if (refreshFailed) {
toast.error('Failed to refresh Codex snapshot. Raw edits were kept.');
return;
}
setRawDraftText(null);
} catch (error) {
toast.error((error as Error).message || 'Failed to refresh Codex snapshot.');
}
}; };
const handleSaveRawConfig = async () => { const handleSaveRawConfig = async () => {
@@ -206,17 +226,24 @@ export function CodexPage() {
parseWarning={ parseWarning={
rawEditorValidation.valid ? rawConfig?.parseError : rawEditorValidation.error rawEditorValidation.valid ? rawConfig?.parseError : rawEditorValidation.error
} }
readWarning={rawConfig?.readError}
value={rawEditorText} value={rawEditorText}
dirty={rawConfigDirty} dirty={rawConfigDirty}
readOnly={Boolean(rawConfig?.readError)}
saving={isSavingRawConfig} saving={isSavingRawConfig}
saveDisabled={ saveDisabled={
!rawConfigDirty || isSavingRawConfig || rawConfigLoading || !rawEditorValidation.valid !rawConfigDirty ||
isSavingRawConfig ||
rawConfigLoading ||
!rawEditorValidation.valid ||
Boolean(rawConfig?.readError)
} }
onChange={(next) => { onChange={(next) => {
setRawEditorDraftText(next); setRawEditorDraftText(next);
}} }}
onSave={handleSaveRawConfig} onSave={handleSaveRawConfig}
onRefresh={refreshAll} onRefresh={refreshAll}
onDiscard={() => setRawDraftText(null)}
language="toml" language="toml"
loadingLabel="Loading config.toml..." loadingLabel="Loading config.toml..."
parseWarningLabel="TOML warning" parseWarningLabel="TOML warning"
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
describe('CodexTopLevelControlsCard', () => {
it('submits only changed fields so untouched unsupported values are preserved upstream', async () => {
const onSave = vi.fn();
render(
<CodexTopLevelControlsCard
values={{
model: null,
modelReasoningEffort: null,
modelProvider: null,
approvalPolicy: null,
sandboxMode: null,
webSearch: null,
toolOutputTokenLimit: null,
personality: null,
}}
providerNames={[]}
onSave={onSave}
/>
);
const saveButton = screen.getByRole('button', { name: 'Save top-level settings' });
expect(saveButton).toBeDisabled();
await userEvent.type(screen.getByPlaceholderText('gpt-5.4'), 'gpt-5.4-mini');
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith({ model: 'gpt-5.4-mini' });
});
});
+2
View File
@@ -77,6 +77,7 @@ const initialRawConfigResponse = {
rawText: 'model = "gpt-5.3-codex"\n', rawText: 'model = "gpt-5.3-codex"\n',
config: { model: 'gpt-5.3-codex' }, config: { model: 'gpt-5.3-codex' },
parseError: null, parseError: null,
readError: null,
}; };
const patchedRawConfigResponse = { const patchedRawConfigResponse = {
@@ -88,6 +89,7 @@ const patchedRawConfigResponse = {
rawText: 'model = "gpt-5.4"\n', rawText: 'model = "gpt-5.4"\n',
config: { model: 'gpt-5.4' }, config: { model: 'gpt-5.4' },
parseError: null, parseError: null,
readError: null,
}; };
const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>; const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>;
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { CLI_SUPPORT_ENTRIES, SUPPORT_NOTICES } from '@/lib/support-updates-catalog';
describe('support-updates catalog codex routing', () => {
it('routes the Codex runtime notice to the Codex dashboard', () => {
const notice = SUPPORT_NOTICES.find((entry) => entry.id === 'codex-target-runtime-support');
expect(notice).toBeDefined();
expect(notice?.routes).toContainEqual({ label: 'Codex CLI', path: '/codex' });
expect(notice?.actions).toContainEqual(
expect.objectContaining({
id: 'open-codex-dashboard',
type: 'route',
path: '/codex',
})
);
});
it('routes the Codex target entry to the Codex dashboard', () => {
const entry = CLI_SUPPORT_ENTRIES.find((item) => item.id === 'codex-target');
expect(entry).toBeDefined();
expect(entry?.routes).toEqual([{ label: 'Codex CLI', path: '/codex' }]);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
const mocks = vi.hoisted(() => ({
useCodex: vi.fn(),
refetchDiagnostics: vi.fn(),
refetchRawConfig: vi.fn(),
saveRawConfigAsync: vi.fn(),
patchConfigAsync: vi.fn(),
}));
vi.mock('@/hooks/use-codex', () => ({
useCodex: mocks.useCodex,
}));
vi.mock('react-resizable-panels', () => ({
PanelGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Panel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PanelResizeHandle: () => <div data-testid="panel-resize-handle" />,
}));
vi.mock('@/components/shared/code-editor', () => ({
CodeEditor: ({
value,
onChange,
readonly,
}: {
value: string;
onChange: (next: string) => void;
readonly?: boolean;
}) => (
<textarea
aria-label="codex raw editor"
value={value}
readOnly={readonly}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock('@/components/compatible-cli/codex-control-center-tab', () => ({
CodexControlCenterTab: () => <div>Control Center</div>,
}));
vi.mock('@/components/compatible-cli/codex-docs-tab', () => ({
CodexDocsTab: () => <div>Docs</div>,
}));
vi.mock('@/components/compatible-cli/codex-overview-tab', () => ({
CodexOverviewTab: () => <div>Overview</div>,
}));
import { CodexPage } from '@/pages/codex';
const diagnostics = {
binary: {
installed: true,
path: '/tmp/codex',
installDir: '/tmp',
source: 'PATH',
version: 'codex-cli 0.118.0-alpha.3',
overridePath: null,
supportsConfigOverrides: true,
},
file: {
label: 'Codex user config',
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
isSymlink: false,
isRegularFile: true,
sizeBytes: 64,
mtimeMs: 100,
parseError: null,
readError: null,
},
workspacePath: '/tmp/workspace',
config: {
model: 'gpt-5.4',
modelReasoningEffort: null,
modelProvider: null,
activeProfile: null,
approvalPolicy: null,
sandboxMode: null,
webSearch: null,
toolOutputTokenLimit: null,
personality: null,
topLevelKeys: ['model'],
profileCount: 0,
profileNames: [],
modelProviderCount: 0,
modelProviders: [],
featureCount: 0,
enabledFeatures: [],
disabledFeatures: [],
trustedProjectCount: 0,
untrustedProjectCount: 0,
projectTrust: [],
mcpServerCount: 0,
mcpServers: [],
},
supportMatrix: [],
warnings: [],
docsReference: {
providerValues: [],
settingsHierarchy: [],
notes: [],
links: [],
providerDocs: [],
},
};
function buildUseCodexResult(overrides?: Partial<ReturnType<typeof mocks.useCodex>>) {
return {
diagnostics,
diagnosticsLoading: false,
diagnosticsError: null,
refetchDiagnostics: mocks.refetchDiagnostics,
rawConfig: {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: 'model = "gpt-5.4"\n',
config: { model: 'gpt-5.4' },
parseError: null,
readError: null,
},
rawConfigLoading: false,
rawConfigError: null,
refetchRawConfig: mocks.refetchRawConfig,
saveRawConfigAsync: mocks.saveRawConfigAsync,
isSavingRawConfig: false,
patchConfigAsync: mocks.patchConfigAsync,
isPatchingConfig: false,
...overrides,
};
}
describe('CodexPage', () => {
beforeEach(() => {
mocks.refetchDiagnostics.mockClear();
mocks.refetchRawConfig.mockClear();
mocks.refetchDiagnostics.mockResolvedValue({ status: 'success', isError: false, error: null });
mocks.refetchRawConfig.mockResolvedValue({ status: 'success', isError: false, error: null });
mocks.saveRawConfigAsync.mockReset();
mocks.patchConfigAsync.mockReset();
});
it('discards local raw TOML edits when the user refreshes the page snapshot successfully', async () => {
mocks.useCodex.mockReturnValue(buildUseCodexResult());
render(<CodexPage />);
const editor = screen.getByLabelText('codex raw editor');
await userEvent.clear(editor);
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
expect(editor).toHaveValue('model = "gpt-5.4-mini"');
await userEvent.click(screen.getByLabelText('Refresh raw config'));
await waitFor(() => expect(mocks.refetchDiagnostics).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mocks.refetchRawConfig).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4"\n')
);
});
it('keeps local raw TOML edits when refresh resolves with an error state', async () => {
mocks.refetchRawConfig.mockResolvedValueOnce({
status: 'error',
isError: true,
error: new Error('Failed to fetch Codex raw config'),
});
mocks.useCodex.mockReturnValue(buildUseCodexResult());
render(<CodexPage />);
const editor = screen.getByLabelText('codex raw editor');
await userEvent.clear(editor);
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
await userEvent.click(screen.getByLabelText('Refresh raw config'));
await waitFor(() => expect(mocks.refetchDiagnostics).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mocks.refetchRawConfig).toHaveBeenCalledTimes(1));
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4-mini"');
expect(screen.getByText('Unsaved')).toBeInTheDocument();
});
it('restores the last fetched snapshot when the user discards local raw TOML edits', async () => {
mocks.useCodex.mockReturnValue(buildUseCodexResult());
render(<CodexPage />);
const editor = screen.getByLabelText('codex raw editor');
await userEvent.clear(editor);
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
const discardButton = screen.getByRole('button', { name: 'Discard' });
expect(discardButton).toBeEnabled();
await userEvent.click(discardButton);
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4"\n');
});
it('shows read errors and makes the raw editor read-only when the file cannot be edited safely', () => {
mocks.useCodex.mockReturnValue(
buildUseCodexResult({
rawConfig: {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: '',
config: null,
parseError: null,
readError: 'Refusing symlink file for safety.',
},
})
);
render(<CodexPage />);
expect(screen.getByText(/Read-only: Refusing symlink file for safety\./)).toBeInTheDocument();
expect(screen.getByLabelText('codex raw editor')).toHaveAttribute('readonly');
});
});