mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(codex): harden runtime targeting and dashboard editing
This commit is contained in:
@@ -198,15 +198,15 @@ Resolves which adapter to use via `resolveTargetType()`:
|
||||
```
|
||||
1. --target <name> flag (highest priority)
|
||||
↓
|
||||
2. Profile config: profileConfig.target field
|
||||
↓
|
||||
3. argv[0] detection (runtime alias pattern):
|
||||
2. argv[0] detection (runtime alias pattern):
|
||||
- ccs-droid → droid
|
||||
- ccsd → droid
|
||||
- ccs-codex → codex
|
||||
- ccsx → codex
|
||||
- ccs → default
|
||||
↓
|
||||
3. Profile config: profileConfig.target field
|
||||
↓
|
||||
4. Fallback: 'claude' (lowest priority)
|
||||
```
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ src/
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── target-adapter.ts # TargetAdapter interface contract
|
||||
│ ├── target-registry.ts # Registry for runtime adapter lookup
|
||||
│ ├── target-resolver.ts # Resolution logic (flag > config > argv[0])
|
||||
│ ├── target-resolver.ts # Resolution logic (flag > argv[0] > config)
|
||||
│ ├── target-metadata.ts # Runtime vs persisted target metadata and alias lists
|
||||
│ ├── target-runtime-compatibility.ts # Guardrails for target/profile combinations
|
||||
│ ├── claude-adapter.ts # Claude Code CLI implementation
|
||||
@@ -254,7 +254,7 @@ src/
|
||||
- Adapter behavior: `src/targets/codex-adapter.ts` and `src/targets/codex-detector.ts` launch native Codex without rewriting `~/.codex/config.toml`; CCS-backed routes use transient `codex -c key=value` overrides and env-key injection.
|
||||
- Dashboard control center: `src/web-server/services/codex-dashboard-service.ts`, `src/web-server/routes/codex-routes.ts`, `ui/src/pages/codex.tsx`, and `ui/src/components/compatible-cli/codex-*.tsx` expose a split-view Codex dashboard with guided editors for top-level settings, trust, profiles, providers, MCP servers, and feature flags plus a raw TOML fallback.
|
||||
- Structured-edit boundary: guided Codex saves intentionally reserialize the whole TOML document, so comments/formatting are normalized and the raw editor remains the fidelity-preserving escape hatch.
|
||||
- Follow-up behavior: structured saves refresh the raw snapshot immediately, structured controls stay disabled while raw TOML is dirty or invalid, project trust paths must be absolute or `~/...`, and feature flags can be reset to default.
|
||||
- Follow-up behavior: structured saves refresh the raw snapshot immediately, refresh discards stale raw drafts, structured controls stay disabled while raw TOML is dirty/invalid/unreadable, project trust paths must be absolute or `~/...`, unsupported upstream top-level shapes are preserved instead of deleted, and feature flags can be reset to default.
|
||||
- Supported Codex flows in v1:
|
||||
- `default`
|
||||
- CLIProxy provider `codex`
|
||||
@@ -279,8 +279,8 @@ The targets module provides an extensible interface for dispatching profiles to
|
||||
|
||||
2. **Target Resolution** - Priority order:
|
||||
- `--target <cli>` flag (CLI argument)
|
||||
- Per-profile `target` field (from config.yaml)
|
||||
- `argv[0]` detection (runtime alias pattern: `ccs-droid` / `ccsd` → droid)
|
||||
- Per-profile `target` field (from config.yaml)
|
||||
- Default: `claude`
|
||||
|
||||
3. **Implementations:**
|
||||
|
||||
@@ -41,7 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, dirty raw-editor guarding for structured controls, project-trust path validation, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file.
|
||||
- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file.
|
||||
- **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow.
|
||||
- **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts.
|
||||
- **2026-03-24**: Official Claude Channels now follow Anthropic's actual runtime contract. CCS blocks auto-enable unless Bun is available, Claude Code is verified at v2.1.80+, and `claude.ai` auth is verified; treats `--allow-dangerously-skip-permissions` as an explicit override; keeps Telegram/Discord bot tokens in Claude's shared `~/.claude/channels/` state (or official `*_STATE_DIR` overrides); and upgrades the dashboard/CLI status flow with Bun/version/auth/state-scope guidance, safer token draft retention on refresh failures, and a non-macOS iMessage toggle that can still be turned off when already selected.
|
||||
|
||||
@@ -58,7 +58,7 @@ CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration w
|
||||
Profile Resolution (CLIProxy, Settings/API, Account-based)
|
||||
|
|
||||
v
|
||||
Target Resolution (--target flag > config > argv[0] > default)
|
||||
Target Resolution (--target flag > argv[0] > config > default)
|
||||
|
|
||||
v
|
||||
Get Target Adapter (Claude, Droid, or Codex)
|
||||
|
||||
@@ -85,19 +85,19 @@ CCS resolves which adapter to use via priority-ordered checks:
|
||||
└─ ccs --target droid glm
|
||||
└─ ccs --target codex
|
||||
|
||||
2. Per-profile config (from ~/.ccs/config.yaml or settings.json)
|
||||
└─ persisted targets are currently only `claude` and `droid`
|
||||
└─ profiles:
|
||||
glm:
|
||||
target: droid
|
||||
|
||||
3. argv[0] detection (runtime alias pattern) — binary name mapping
|
||||
2. argv[0] detection (runtime alias pattern) — binary name mapping
|
||||
└─ ccs-droid (explicit alias) → droid
|
||||
└─ ccsd (legacy shortcut) → droid
|
||||
└─ ccs-codex (explicit alias) → codex
|
||||
└─ ccsx (short alias) → codex
|
||||
└─ ccs (regular command) → default
|
||||
|
||||
3. Per-profile config (from ~/.ccs/config.yaml or settings.json)
|
||||
└─ persisted targets are currently only `claude` and `droid`
|
||||
└─ profiles:
|
||||
glm:
|
||||
target: droid
|
||||
|
||||
4. Fallback: 'claude' — lowest priority
|
||||
```
|
||||
|
||||
@@ -117,18 +117,18 @@ export function resolveTargetType(
|
||||
return parsed.targetOverride;
|
||||
}
|
||||
|
||||
// 2. Check profile config
|
||||
if (profileConfig?.target) {
|
||||
// Persisted targets intentionally exclude runtime-only codex.
|
||||
return profileConfig.target;
|
||||
}
|
||||
|
||||
// 3. Check argv[0] (binary name)
|
||||
// 2. Check argv[0] (binary name)
|
||||
const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, '');
|
||||
if (ARGV0_TARGET_MAP[binName]) {
|
||||
return ARGV0_TARGET_MAP[binName];
|
||||
}
|
||||
|
||||
// 3. Check profile config
|
||||
if (profileConfig?.target) {
|
||||
// Persisted targets intentionally exclude runtime-only codex.
|
||||
return profileConfig.target;
|
||||
}
|
||||
|
||||
// 4. Default to claude
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
+64
-8
@@ -90,8 +90,12 @@ interface DetectedProfile {
|
||||
interface RuntimeReasoningResolution {
|
||||
argsWithoutReasoningFlags: string[];
|
||||
reasoningOverride: string | number | undefined;
|
||||
reasoningSource: 'flag' | 'env' | undefined;
|
||||
sourceDisplay: string | undefined;
|
||||
}
|
||||
|
||||
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
|
||||
/**
|
||||
* Smart profile detection
|
||||
*/
|
||||
@@ -122,9 +126,21 @@ function resolveRuntimeReasoningFlags(
|
||||
return {
|
||||
argsWithoutReasoningFlags: runtime.argsWithoutReasoningFlags,
|
||||
reasoningOverride: runtime.reasoningOverride,
|
||||
reasoningSource: runtime.sourceFlag
|
||||
? 'flag'
|
||||
: runtime.reasoningOverride !== undefined
|
||||
? 'env'
|
||||
: undefined,
|
||||
sourceDisplay: runtime.sourceDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCodexRuntimeReasoningOverride(
|
||||
value: string | number | undefined
|
||||
): string | undefined {
|
||||
return typeof value === 'string' && CODEX_RUNTIME_REASONING_LEVELS.has(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function exitWithRuntimeReasoningFlagError(
|
||||
message: string,
|
||||
options: {
|
||||
@@ -417,6 +433,9 @@ async function main(): Promise<void> {
|
||||
|
||||
// Resolve non-claude target adapter once.
|
||||
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,
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (profileInfo.type !== 'settings') {
|
||||
if (profileInfo.type === 'settings') {
|
||||
resolvedSettingsPath = profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
: getSettingsPath(profileInfo.name);
|
||||
resolvedSettings = loadSettings(resolvedSettingsPath);
|
||||
resolvedCliproxyBridge = resolveCliproxyBridgeMetadata(resolvedSettings);
|
||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: resolvedTarget,
|
||||
profileType: profileInfo.type,
|
||||
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
|
||||
});
|
||||
if (!compatibility.supported) {
|
||||
console.error(
|
||||
fail(
|
||||
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
||||
)
|
||||
);
|
||||
if (compatibility.suggestion) {
|
||||
console.error(info(compatibility.suggestion));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: resolvedTarget,
|
||||
profileType: profileInfo.type,
|
||||
@@ -524,7 +565,7 @@ async function main(): Promise<void> {
|
||||
} catch (error) {
|
||||
if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) {
|
||||
exitWithRuntimeReasoningFlagError(error.message, {
|
||||
codexAliasLevels: 'medium|high|xhigh',
|
||||
codexAliasLevels: 'minimal|low|medium|high|xhigh',
|
||||
includeDroidExecExample: true,
|
||||
});
|
||||
}
|
||||
@@ -534,7 +575,20 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
const runtime = resolveRuntimeReasoningFlags(remainingArgs, process.env.CCS_THINKING);
|
||||
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
||||
runtimeReasoningOverride = runtime.reasoningOverride;
|
||||
const normalizedReasoning = normalizeCodexRuntimeReasoningOverride(
|
||||
runtime.reasoningOverride
|
||||
);
|
||||
if (runtime.reasoningOverride !== undefined && !normalizedReasoning) {
|
||||
if (runtime.reasoningSource === 'flag') {
|
||||
throw new DroidReasoningFlagError(
|
||||
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.',
|
||||
'--effort'
|
||||
);
|
||||
}
|
||||
runtimeReasoningOverride = undefined;
|
||||
} else {
|
||||
runtimeReasoningOverride = normalizedReasoning;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DroidReasoningFlagError) {
|
||||
exitWithRuntimeReasoningFlagError(error.message, {
|
||||
@@ -777,11 +831,13 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
const expandedSettingsPath = profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
: getSettingsPath(profileInfo.name);
|
||||
const settings = loadSettings(expandedSettingsPath);
|
||||
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
||||
const expandedSettingsPath =
|
||||
resolvedSettingsPath ??
|
||||
(profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
: getSettingsPath(profileInfo.name));
|
||||
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
|
||||
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: resolvedTarget,
|
||||
|
||||
@@ -236,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
'ccs <provider> --thinking <value>',
|
||||
'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> --no-1m', 'Force standard context / clear [1m]'],
|
||||
['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 <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'],
|
||||
['', ''],
|
||||
['Droid exec:', 'Use native Droid flag: --reasoning-effort <level>'],
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface CodexRawConfigResponse {
|
||||
rawText: string;
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
readError: string | null;
|
||||
}
|
||||
|
||||
export interface CodexTopLevelSettingsPatch {
|
||||
|
||||
@@ -28,6 +28,13 @@ function buildConfigOverrideArgs(overrides: string[]): string[] {
|
||||
return overrides.flatMap((override) => ['-c', override]);
|
||||
}
|
||||
|
||||
function buildConfigOverrideSupportError(binaryInfo?: TargetBinaryInfo): Error {
|
||||
const versionSummary = binaryInfo?.version ? ` (${binaryInfo.version})` : '';
|
||||
return new Error(
|
||||
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles or runtime reasoning overrides.`
|
||||
);
|
||||
}
|
||||
|
||||
function findDisallowedCodexManagedFlags(args: string[]): string[] {
|
||||
const disallowed = new Set<string>();
|
||||
|
||||
@@ -91,6 +98,9 @@ export class CodexAdapter implements TargetAdapter {
|
||||
|
||||
if (profileType === 'default') {
|
||||
if (reasoningOverride) {
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
throw buildConfigOverrideSupportError(options?.binaryInfo);
|
||||
}
|
||||
return [
|
||||
...buildConfigOverrideArgs([
|
||||
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
|
||||
@@ -102,10 +112,7 @@ export class CodexAdapter implements TargetAdapter {
|
||||
}
|
||||
|
||||
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||
const versionSummary = options?.binaryInfo?.version ? ` (${options.binaryInfo.version})` : '';
|
||||
throw new Error(
|
||||
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles.`
|
||||
);
|
||||
throw buildConfigOverrideSupportError(options?.binaryInfo);
|
||||
}
|
||||
|
||||
if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) {
|
||||
|
||||
@@ -70,8 +70,8 @@ const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high
|
||||
const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']);
|
||||
const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
||||
const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']);
|
||||
const PERSONALITY_VALUES = new Set(['default', 'pragmatic', 'concise', 'direct']);
|
||||
const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'ask']);
|
||||
const PERSONALITY_VALUES = new Set(['none', 'friendly', 'pragmatic']);
|
||||
const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'untrusted']);
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
delete target[key];
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
target[key] = value.trim();
|
||||
@@ -129,7 +140,7 @@ function setEnumStringField(
|
||||
label: string
|
||||
) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
delete target[key];
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,7 +156,7 @@ function setEnumStringField(
|
||||
|
||||
function setBooleanField(target: Record<string, unknown>, key: string, value: unknown) {
|
||||
if (typeof value !== 'boolean') {
|
||||
delete target[key];
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
target[key] = value;
|
||||
@@ -158,7 +169,7 @@ function setNumberField(
|
||||
options: { integer?: boolean; min?: number } = {}
|
||||
) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
delete target[key];
|
||||
deleteFieldUnlessUnsupported(target, key);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,6 +210,24 @@ function assertPatchableToml(fileProbe: {
|
||||
return asObject(fileProbe.config) ?? {};
|
||||
}
|
||||
|
||||
function summarizeApprovalPolicy(value: unknown): string | null {
|
||||
const stringValue = asString(value);
|
||||
if (stringValue) {
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
const objectValue = asObject(value);
|
||||
if (!objectValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasOwn(objectValue, 'granular')) {
|
||||
return 'granular (custom)';
|
||||
}
|
||||
|
||||
return 'custom object';
|
||||
}
|
||||
|
||||
function applyTopLevelSettingsPatch(
|
||||
target: Record<string, unknown>,
|
||||
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, 'required')) setBooleanField(nextServer, 'required', values.required);
|
||||
if (hasOwn(values, 'startupTimeoutSec')) {
|
||||
setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
delete nextServer.startup_timeout_ms;
|
||||
setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, { min: 1 });
|
||||
}
|
||||
if (hasOwn(values, 'toolTimeoutSec')) {
|
||||
setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, { min: 1 });
|
||||
}
|
||||
if (hasOwn(values, 'enabledTools')) {
|
||||
const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools');
|
||||
@@ -506,7 +530,9 @@ export function resolveCodexConfigPaths(
|
||||
): CodexConfigPaths {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const baseDir = env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex');
|
||||
const baseDir = path.resolve(
|
||||
env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(homeDir, '.codex')
|
||||
);
|
||||
const baseDirDisplay = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex';
|
||||
|
||||
return {
|
||||
@@ -596,7 +622,8 @@ export function summarizeCodexMcpServers(value: unknown): CodexMcpServerDiagnost
|
||||
|
||||
const startupTimeoutMs = asNumber(server.startup_timeout_ms);
|
||||
const startupTimeoutSec =
|
||||
asNumber(server.startup_timeout_sec) ?? (startupTimeoutMs ? startupTimeoutMs / 1000 : null);
|
||||
asNumber(server.startup_timeout_sec) ??
|
||||
(startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null);
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -727,7 +754,7 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
|
||||
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||
modelProvider: asString(config?.model_provider),
|
||||
activeProfile,
|
||||
approvalPolicy: asString(config?.approval_policy),
|
||||
approvalPolicy: summarizeApprovalPolicy(config?.approval_policy),
|
||||
sandboxMode: asString(config?.sandbox_mode),
|
||||
webSearch: asString(config?.web_search),
|
||||
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
|
||||
@@ -768,6 +795,7 @@ export async function getCodexRawConfig(): Promise<CodexRawConfigResponse> {
|
||||
rawText: fileProbe.rawText,
|
||||
config: fileProbe.config,
|
||||
parseError: fileProbe.diagnostics.parseError,
|
||||
readError: fileProbe.diagnostics.readError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -827,7 +855,7 @@ export async function patchCodexConfig(
|
||||
const saved = await writeTomlFileAtomic({
|
||||
filePath: paths.configPath,
|
||||
rawText,
|
||||
expectedMtime: input.expectedMtime,
|
||||
expectedMtime: input.expectedMtime ?? fileProbe.diagnostics.mtimeMs ?? undefined,
|
||||
fileLabel: 'config.toml',
|
||||
});
|
||||
|
||||
@@ -840,5 +868,6 @@ export async function patchCodexConfig(
|
||||
rawText,
|
||||
config: nextConfig,
|
||||
parseError: null,
|
||||
readError: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,11 +30,36 @@ describe('CodexAdapter', () => {
|
||||
apiKey: '',
|
||||
reasoningOverride: 'medium',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']);
|
||||
});
|
||||
|
||||
test('rejects default-mode reasoning overrides when codex lacks config override support', () => {
|
||||
expect(() =>
|
||||
adapter.buildArgs('default', ['--search'], {
|
||||
profileType: 'default',
|
||||
creds: {
|
||||
profile: 'default',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
reasoningOverride: 'high',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.1.0',
|
||||
features: [],
|
||||
},
|
||||
})
|
||||
).toThrow(/does not advertise --config overrides/);
|
||||
});
|
||||
|
||||
test('injects transient config overrides for CCS-backed launches', () => {
|
||||
const args = adapter.buildArgs('codex', ['--search'], {
|
||||
profileType: 'cliproxy',
|
||||
@@ -97,6 +122,29 @@ describe('CodexAdapter', () => {
|
||||
).toThrow(/does not allow --profile\/-p/);
|
||||
});
|
||||
|
||||
test('rejects user-supplied --config overrides for CCS-backed launches', () => {
|
||||
const options = {
|
||||
profileType: 'cliproxy' as const,
|
||||
creds: {
|
||||
profile: 'codex',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
apiKey: 'cliproxy-token',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => adapter.buildArgs('codex', ['-c', 'model="other"', '--search'], options)).toThrow(
|
||||
/does not allow --config\/-c/
|
||||
);
|
||||
expect(() =>
|
||||
adapter.buildArgs('codex', ['--config=model="other"', '--search'], options)
|
||||
).toThrow(/does not allow --config\/-c/);
|
||||
});
|
||||
|
||||
test('rejects unsupported reasoning override values for CCS-backed launches', () => {
|
||||
expect(() =>
|
||||
adapter.buildArgs('codex', ['--search'], {
|
||||
|
||||
@@ -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', () => {
|
||||
const resolved = resolveCodexConfigPaths({
|
||||
env: {
|
||||
CODEX_HOME: '/tmp/custom-codex-home',
|
||||
CODEX_HOME: './custom-codex-home',
|
||||
} as NodeJS.ProcessEnv,
|
||||
homeDir: '/Users/tester',
|
||||
});
|
||||
|
||||
expect(resolved.baseDir).toBe('/tmp/custom-codex-home');
|
||||
expect(resolved.baseDir).toBe(path.resolve('./custom-codex-home'));
|
||||
expect(resolved.baseDirDisplay).toBe('$CODEX_HOME');
|
||||
expect(resolved.configPath).toBe('/tmp/custom-codex-home/config.toml');
|
||||
expect(resolved.configPath).toBe(path.join(path.resolve('./custom-codex-home'), 'config.toml'));
|
||||
expect(resolved.configDisplayPath).toBe('$CODEX_HOME/config.toml');
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('codex-dashboard-service', () => {
|
||||
});
|
||||
const projects = summarizeCodexProjectTrust({
|
||||
'/tmp/a': { trust_level: 'trusted' },
|
||||
'/tmp/b': { trust_level: 'ask' },
|
||||
'/tmp/b': { trust_level: 'untrusted' },
|
||||
});
|
||||
const servers = summarizeCodexMcpServers({
|
||||
stdio: {
|
||||
@@ -133,6 +133,7 @@ describe('codex-dashboard-service', () => {
|
||||
expect(raw.path).toBe('$CODEX_HOME/config.toml');
|
||||
expect(raw.rawText).toBe('');
|
||||
expect(raw.config).toBeNull();
|
||||
expect(raw.readError).toBeNull();
|
||||
});
|
||||
|
||||
it('returns parseError when config.toml is invalid TOML', async () => {
|
||||
@@ -145,6 +146,19 @@ describe('codex-dashboard-service', () => {
|
||||
expect(raw.config).toBeNull();
|
||||
});
|
||||
|
||||
it('returns readError when config.toml is a symlink', async () => {
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
const targetPath = path.join(testRoot, 'linked.toml');
|
||||
fs.writeFileSync(targetPath, 'model = "gpt-5.4"\n');
|
||||
fs.symlinkSync(targetPath, configPath);
|
||||
|
||||
const raw = await getCodexRawConfig();
|
||||
|
||||
expect(raw.exists).toBe(true);
|
||||
expect(raw.readError).toContain('Refusing symlink file');
|
||||
expect(raw.config).toBeNull();
|
||||
});
|
||||
|
||||
it('includes docs links, support matrix, and config summaries in diagnostics', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
@@ -170,7 +184,7 @@ wire_api = "responses"
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."/tmp/project-b"]
|
||||
trust_level = "ask"
|
||||
trust_level = "untrusted"
|
||||
|
||||
[mcp_servers.playwright]
|
||||
command = "npx"
|
||||
@@ -204,6 +218,17 @@ model = "gpt-5.4"
|
||||
expect(diagnostics.supportMatrix.some((entry) => entry.id === 'default')).toBe(true);
|
||||
});
|
||||
|
||||
it('summarizes granular approval policies without flattening them to null', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
'approval_policy = { granular = { edit = "on-request" } }\n'
|
||||
);
|
||||
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
|
||||
expect(diagnostics.config.approvalPolicy).toBe('granular (custom)');
|
||||
});
|
||||
|
||||
it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => {
|
||||
writeCodexStub({ helpText: ' -p, --profile <CONFIG_PROFILE>\n' });
|
||||
fs.writeFileSync(
|
||||
@@ -289,7 +314,7 @@ bearer_token = "secret"
|
||||
sandboxMode: 'workspace-write',
|
||||
webSearch: 'cached',
|
||||
toolOutputTokenLimit: 12000,
|
||||
personality: 'pragmatic',
|
||||
personality: 'friendly',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -304,12 +329,51 @@ bearer_token = "secret"
|
||||
expect(diagnostics.config.model).toBe('gpt-5.4');
|
||||
expect(diagnostics.config.modelReasoningEffort).toBe('high');
|
||||
expect(diagnostics.config.toolOutputTokenLimit).toBe(12000);
|
||||
expect(diagnostics.config.personality).toBe('pragmatic');
|
||||
expect(diagnostics.config.personality).toBe('friendly');
|
||||
expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a');
|
||||
expect(result.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(result.config?.model).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('allows structured patches on existing config.toml even when expectedMtime is omitted', async () => {
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
|
||||
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(result.rawText).toContain('[features]');
|
||||
expect(result.rawText).toContain('multi_agent = true');
|
||||
expect(result.config?.features).toEqual({ multi_agent: true });
|
||||
});
|
||||
|
||||
it('preserves unsupported approval_policy objects when structured saves touch other fields', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
'model = "gpt-5.4"\napproval_policy = { granular = { edit = "on-request" } }\n'
|
||||
);
|
||||
|
||||
const current = await getCodexRawConfig();
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'top-level',
|
||||
expectedMtime: current.mtime,
|
||||
values: {
|
||||
model: 'gpt-5.4-mini',
|
||||
approvalPolicy: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('model = "gpt-5.4-mini"');
|
||||
expect(result.rawText).toContain('[approval_policy.granular]');
|
||||
expect(result.rawText).toContain('edit = "on-request"');
|
||||
expect(result.config?.approval_policy).toEqual({
|
||||
granular: { edit: 'on-request' },
|
||||
});
|
||||
});
|
||||
|
||||
it('expands home paths for project trust and rejects relative paths', async () => {
|
||||
const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace');
|
||||
const expanded = await patchCodexConfig({
|
||||
@@ -384,6 +448,46 @@ bearer_token = "secret"
|
||||
expect(profileResult.config?.profile).toBe('deep-review');
|
||||
});
|
||||
|
||||
it('accepts non-integer MCP timeout values documented by upstream Codex', async () => {
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
action: 'upsert',
|
||||
name: 'streaming',
|
||||
values: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
startupTimeoutSec: 1.5,
|
||||
toolTimeoutSec: 2.25,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('startup_timeout_sec = 1.5');
|
||||
expect(result.rawText).toContain('tool_timeout_sec = 2.25');
|
||||
});
|
||||
|
||||
it('rewrites legacy startup_timeout_ms keys when editing MCP server timeouts', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
['[mcp_servers.streaming]', 'command = "npx"', 'startup_timeout_ms = 1500', ''].join('\n')
|
||||
);
|
||||
const raw = await getCodexRawConfig();
|
||||
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
action: 'upsert',
|
||||
name: 'streaming',
|
||||
expectedMtime: raw.mtime,
|
||||
values: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
startupTimeoutSec: 2.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('startup_timeout_sec = 2.5');
|
||||
expect(result.rawText).not.toContain('startup_timeout_ms');
|
||||
});
|
||||
|
||||
it('patches streamable-http mcp servers through structured controls', async () => {
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
|
||||
@@ -76,12 +76,14 @@ describe('codex routes', () => {
|
||||
rawText: string;
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
readError: string | null;
|
||||
};
|
||||
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.exists).toBe(true);
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
expect(json.parseError).toBeNull();
|
||||
expect(json.readError).toBeNull();
|
||||
expect(json.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(json.rawText).toContain('sandbox_mode = "workspace-write"');
|
||||
expect(json.config?.model).toBe('gpt-5.4');
|
||||
@@ -112,6 +114,32 @@ describe('codex routes', () => {
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('allows PATCH /config/patch on an existing config.toml without expectedMtime', async () => {
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
rawText: string;
|
||||
config: Record<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 () => {
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -51,7 +51,7 @@ function ProjectTrustComposer({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="trusted">trusted</SelectItem>
|
||||
<SelectItem value="ask">ask</SelectItem>
|
||||
<SelectItem value="untrusted">untrusted</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
|
||||
@@ -117,7 +117,7 @@ export function CodexProjectTrustCard({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onSave(entry.path, entry.trustLevel === 'trusted' ? 'ask' : 'trusted')
|
||||
onSave(entry.path, entry.trustLevel === 'trusted' ? 'untrusted' : 'trusted')
|
||||
}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,32 @@ function withCurrentValue(options: string[], current: string | null | undefined)
|
||||
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 {
|
||||
initialValues: CodexTopLevelSettingsView;
|
||||
providerNames: string[];
|
||||
@@ -62,10 +88,9 @@ function TopLevelControlsForm({
|
||||
draft.sandboxMode
|
||||
);
|
||||
const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch);
|
||||
const personalityOptions = withCurrentValue(
|
||||
['default', 'pragmatic', 'concise', 'direct'],
|
||||
draft.personality
|
||||
);
|
||||
const personalityOptions = withCurrentValue(['none', 'friendly', 'pragmatic'], draft.personality);
|
||||
const patch = buildTopLevelPatch(initialValues, draft);
|
||||
const hasChanges = Object.keys(patch).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -242,7 +267,7 @@ function TopLevelControlsForm({
|
||||
</div>
|
||||
|
||||
<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}
|
||||
Save top-level settings
|
||||
</Button>
|
||||
@@ -264,7 +289,7 @@ export function CodexTopLevelControlsCard({
|
||||
title="Top-level controls"
|
||||
badge="config.toml"
|
||||
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}
|
||||
>
|
||||
<TopLevelControlsForm
|
||||
|
||||
@@ -12,13 +12,16 @@ interface RawConfigEditorPanelProps {
|
||||
pathLabel: string;
|
||||
loading: boolean;
|
||||
parseWarning: string | null | undefined;
|
||||
readWarning?: string | null | undefined;
|
||||
value: string;
|
||||
dirty: boolean;
|
||||
readOnly?: boolean;
|
||||
saving: boolean;
|
||||
saveDisabled: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
onRefresh: () => Promise<void> | void;
|
||||
onDiscard?: () => void;
|
||||
language?: 'json' | 'yaml' | 'toml';
|
||||
loadingLabel?: string;
|
||||
parseWarningLabel?: string;
|
||||
@@ -30,13 +33,16 @@ export function RawConfigEditorPanel({
|
||||
pathLabel,
|
||||
loading,
|
||||
parseWarning,
|
||||
readWarning,
|
||||
value,
|
||||
dirty,
|
||||
readOnly = false,
|
||||
saving,
|
||||
saveDisabled,
|
||||
onChange,
|
||||
onSave,
|
||||
onRefresh,
|
||||
onDiscard,
|
||||
language = 'json',
|
||||
loadingLabel = 'Loading settings.json...',
|
||||
parseWarningLabel = 'Parse warning',
|
||||
@@ -76,11 +82,16 @@ export function RawConfigEditorPanel({
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
{onDiscard ? (
|
||||
<Button variant="outline" size="sm" onClick={onDiscard} disabled={!dirty || loading}>
|
||||
Discard
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</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' : '')} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -100,12 +111,18 @@ export function RawConfigEditorPanel({
|
||||
{parseWarningLabel}: {parseWarning}
|
||||
</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="h-full rounded-md border overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
language={language}
|
||||
readonly={readOnly}
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
|
||||
@@ -97,6 +97,7 @@ export function useCodex() {
|
||||
rawText: variables.rawText,
|
||||
config: parsed.config,
|
||||
parseError: parsed.parseError,
|
||||
readError: null,
|
||||
};
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] });
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
||||
import type {
|
||||
CompatibleCliDocLink,
|
||||
CompatibleCliProviderDocLink,
|
||||
} from '@shared/compatible-cli-contracts';
|
||||
|
||||
export interface DroidBinaryDiagnostics {
|
||||
installed: boolean;
|
||||
@@ -36,22 +40,6 @@ export interface DroidCustomModelDiagnostics {
|
||||
apiKeyPreview: string | null;
|
||||
}
|
||||
|
||||
export interface CompatibleCliDocLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
category: 'overview' | 'configuration' | 'byok' | 'reference';
|
||||
source: 'factory' | 'provider';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CompatibleCliProviderDocLink {
|
||||
provider: string;
|
||||
label: string;
|
||||
apiFormat: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DroidDashboardDiagnostics {
|
||||
binary: DroidBinaryDiagnostics;
|
||||
files: {
|
||||
|
||||
@@ -184,6 +184,7 @@ export function readCodexMcpServers(config: Record<string, unknown> | null): Cod
|
||||
const server = asObject(value);
|
||||
if (!server) return null;
|
||||
const transport = asString(server.command) ? 'stdio' : 'streamable-http';
|
||||
const startupTimeoutMs = asNumber(server.startup_timeout_ms);
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
@@ -192,7 +193,9 @@ export function readCodexMcpServers(config: Record<string, unknown> | null): Cod
|
||||
url: asString(server.url),
|
||||
enabled: server.enabled !== false,
|
||||
required: server.required === true,
|
||||
startupTimeoutSec: asNumber(server.startup_timeout_sec),
|
||||
startupTimeoutSec:
|
||||
asNumber(server.startup_timeout_sec) ??
|
||||
(startupTimeoutMs !== null ? startupTimeoutMs / 1000 : null),
|
||||
toolTimeoutSec: asNumber(server.tool_timeout_sec),
|
||||
enabledTools: asStringArray(server.enabled_tools),
|
||||
disabledTools: asStringArray(server.disabled_tools),
|
||||
|
||||
+4
-4
@@ -839,7 +839,7 @@ const resources = {
|
||||
supportLine1Suffix: '(token-based)',
|
||||
supportLine2Prefix: 'Reasoning effort:',
|
||||
supportLine2SuffixPrefix: '(suffix or ',
|
||||
supportLine2SuffixPostfix: ': medium/high/xhigh)',
|
||||
supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
|
||||
supportLine3Prefix: 'Codex suffixes pin effort (for example ',
|
||||
supportLine3Suffix: '); unsuffixed models use Thinking mode.',
|
||||
},
|
||||
@@ -2012,7 +2012,7 @@ const resources = {
|
||||
supportLine1Suffix: '(基于 token)',
|
||||
supportLine2Prefix: '推理强度:',
|
||||
supportLine2SuffixPrefix: '(后缀或 ',
|
||||
supportLine2SuffixPostfix: ':medium/high/xhigh)',
|
||||
supportLine2SuffixPostfix: ':minimal/low/medium/high/xhigh)',
|
||||
supportLine3Prefix: 'Codex 后缀会固定强度(例如 ',
|
||||
supportLine3Suffix: ');无后缀模型使用 Thinking mode。',
|
||||
},
|
||||
@@ -3232,7 +3232,7 @@ const resources = {
|
||||
supportLine1Suffix: '(dựa trên token)',
|
||||
supportLine2Prefix: 'Nỗ lực lý luận:',
|
||||
supportLine2SuffixPrefix: '(hậu tố hoặc ',
|
||||
supportLine2SuffixPostfix: ': medium/high/xhigh)',
|
||||
supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
|
||||
supportLine3Prefix: 'Hậu tố Codex cố định mức effort (ví dụ ',
|
||||
supportLine3Suffix: '); model không hậu tố sẽ dùng chế độ Thinking.',
|
||||
},
|
||||
@@ -4469,7 +4469,7 @@ const resources = {
|
||||
supportLine1Suffix: '(トークンベース)',
|
||||
supportLine2Prefix: '推論強度:',
|
||||
supportLine2SuffixPrefix: '(サフィックス、または ',
|
||||
supportLine2SuffixPostfix: ': medium/high/xhigh)',
|
||||
supportLine2SuffixPostfix: ': minimal/low/medium/high/xhigh)',
|
||||
supportLine3Prefix: 'Codex のサフィックスは推論強度を固定します(例: ',
|
||||
supportLine3Suffix: ')。サフィックスなしのモデルは思考モードを使います。',
|
||||
},
|
||||
|
||||
@@ -87,17 +87,14 @@ export const SUPPORT_NOTICES: SupportNotice[] = [
|
||||
command: 'ccs codex --target codex "your prompt"',
|
||||
},
|
||||
{
|
||||
id: 'open-cliproxy-codex',
|
||||
label: 'Open Codex provider settings',
|
||||
description: 'Review Codex provider and bridge flows in the dashboard.',
|
||||
id: 'open-codex-dashboard',
|
||||
label: 'Open Codex dashboard',
|
||||
description: 'Review Codex runtime support, config layers, and dashboard setup flows.',
|
||||
type: 'route',
|
||||
path: '/cliproxy',
|
||||
path: '/codex',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{ label: 'CLIProxy', path: '/cliproxy' },
|
||||
{ label: 'API Profiles', path: '/providers' },
|
||||
],
|
||||
routes: [{ label: 'Codex CLI', path: '/codex' }],
|
||||
commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex "your prompt"'],
|
||||
},
|
||||
{
|
||||
@@ -243,10 +240,7 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
|
||||
auth: 'Native Codex auth for default mode, env_key injection for CCS-backed routes',
|
||||
model: 'Native Codex config or routed Codex model mapping from CLIProxy',
|
||||
},
|
||||
routes: [
|
||||
{ label: 'CLIProxy', path: '/cliproxy' },
|
||||
{ label: 'API Profiles', path: '/providers' },
|
||||
],
|
||||
routes: [{ label: 'Codex CLI', path: '/codex' }],
|
||||
commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'],
|
||||
notes:
|
||||
'Runtime-only in v1. Saved default targets for API profiles and CLIProxy variants remain claude or droid.',
|
||||
|
||||
+37
-10
@@ -46,16 +46,22 @@ export function CodexPage() {
|
||||
: { valid: true as const };
|
||||
const controlsConfig = rawConfig?.config ?? null;
|
||||
const structuredControlsDisabled =
|
||||
rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null;
|
||||
rawConfigLoading ||
|
||||
!rawConfig ||
|
||||
rawConfigDirty ||
|
||||
rawConfig?.parseError !== null ||
|
||||
rawConfig?.readError !== null;
|
||||
const controlsDisabledReason = rawConfigError
|
||||
? 'Structured controls unavailable: failed to load the current config.toml.'
|
||||
: rawConfigDirty
|
||||
? rawEditorValidation.valid
|
||||
? 'Save or discard raw TOML edits before using structured controls.'
|
||||
: 'Fix or discard raw TOML edits before using structured controls.'
|
||||
: rawConfig?.parseError
|
||||
? `Structured controls disabled: ${rawConfig.parseError}`
|
||||
: null;
|
||||
: rawConfig?.readError
|
||||
? `Structured controls unavailable: ${rawConfig.readError}`
|
||||
: rawConfigDirty
|
||||
? rawEditorValidation.valid
|
||||
? 'Save or discard raw TOML edits before using structured controls.'
|
||||
: 'Fix or discard raw TOML edits before using structured controls.'
|
||||
: rawConfig?.parseError
|
||||
? `Structured controls disabled: ${rawConfig.parseError}`
|
||||
: null;
|
||||
|
||||
const topLevelSettings = useMemo(
|
||||
() => readCodexTopLevelSettings(controlsConfig),
|
||||
@@ -82,7 +88,21 @@ export function CodexPage() {
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([refetchDiagnostics(), refetchRawConfig()]);
|
||||
try {
|
||||
const results = await Promise.all([refetchDiagnostics(), refetchRawConfig()]);
|
||||
const refreshFailed = results.some(
|
||||
(result) => !result || result.status === 'error' || result.isError || result.error
|
||||
);
|
||||
|
||||
if (refreshFailed) {
|
||||
toast.error('Failed to refresh Codex snapshot. Raw edits were kept.');
|
||||
return;
|
||||
}
|
||||
|
||||
setRawDraftText(null);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to refresh Codex snapshot.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRawConfig = async () => {
|
||||
@@ -206,17 +226,24 @@ export function CodexPage() {
|
||||
parseWarning={
|
||||
rawEditorValidation.valid ? rawConfig?.parseError : rawEditorValidation.error
|
||||
}
|
||||
readWarning={rawConfig?.readError}
|
||||
value={rawEditorText}
|
||||
dirty={rawConfigDirty}
|
||||
readOnly={Boolean(rawConfig?.readError)}
|
||||
saving={isSavingRawConfig}
|
||||
saveDisabled={
|
||||
!rawConfigDirty || isSavingRawConfig || rawConfigLoading || !rawEditorValidation.valid
|
||||
!rawConfigDirty ||
|
||||
isSavingRawConfig ||
|
||||
rawConfigLoading ||
|
||||
!rawEditorValidation.valid ||
|
||||
Boolean(rawConfig?.readError)
|
||||
}
|
||||
onChange={(next) => {
|
||||
setRawEditorDraftText(next);
|
||||
}}
|
||||
onSave={handleSaveRawConfig}
|
||||
onRefresh={refreshAll}
|
||||
onDiscard={() => setRawDraftText(null)}
|
||||
language="toml"
|
||||
loadingLabel="Loading config.toml..."
|
||||
parseWarningLabel="TOML warning"
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,7 @@ const initialRawConfigResponse = {
|
||||
rawText: 'model = "gpt-5.3-codex"\n',
|
||||
config: { model: 'gpt-5.3-codex' },
|
||||
parseError: null,
|
||||
readError: null,
|
||||
};
|
||||
|
||||
const patchedRawConfigResponse = {
|
||||
@@ -88,6 +89,7 @@ const patchedRawConfigResponse = {
|
||||
rawText: 'model = "gpt-5.4"\n',
|
||||
config: { model: 'gpt-5.4' },
|
||||
parseError: null,
|
||||
readError: null,
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user