feat(codex): add dashboard control center

- add guided editors for top-level settings, trust, profiles, providers, MCP, and features

- refresh raw snapshots after patch saves to avoid stale mtime conflicts

- block structured saves while raw TOML is dirty and add route plus hook coverage
This commit is contained in:
Tam Nhu Tran
2026-03-29 13:14:15 -04:00
parent 3c52b1ab6d
commit b47aa0d28d
18 changed files with 2959 additions and 57 deletions
+30
View File
@@ -5,6 +5,7 @@ import {
CodexRawConfigValidationError, CodexRawConfigValidationError,
getCodexDashboardDiagnostics, getCodexDashboardDiagnostics,
getCodexRawConfig, getCodexRawConfig,
patchCodexConfig,
saveCodexRawConfig, saveCodexRawConfig,
} from '../services/codex-dashboard-service'; } from '../services/codex-dashboard-service';
@@ -56,4 +57,33 @@ router.put('/config/raw', async (req: Request, res: Response): Promise<void> =>
} }
}); });
router.patch('/config/patch', async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body ?? {};
if (typeof body.kind !== 'string' || body.kind.trim().length === 0) {
res.status(400).json({ error: 'kind is required.' });
return;
}
if (
body.expectedMtime !== undefined &&
(typeof body.expectedMtime !== 'number' || !Number.isFinite(body.expectedMtime))
) {
res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' });
return;
}
res.json(await patchCodexConfig(body));
} catch (error) {
if (error instanceof CodexRawConfigValidationError) {
res.status(400).json({ error: error.message });
return;
}
if (error instanceof CodexRawConfigConflictError) {
res.status(409).json({ error: error.message, mtime: error.mtime });
return;
}
res.status(500).json({ error: (error as Error).message });
}
});
export default router; export default router;
@@ -6,6 +6,8 @@ import {
getCodexBinaryInfo, getCodexBinaryInfo,
} from '../../targets/codex-detector'; } from '../../targets/codex-detector';
import type { import type {
CodexConfigPatchInput,
CodexConfigPatchResult,
CodexDashboardDiagnostics, CodexDashboardDiagnostics,
CodexFeatureFlagDiagnostics, CodexFeatureFlagDiagnostics,
CodexMcpServerDiagnostics, CodexMcpServerDiagnostics,
@@ -18,6 +20,7 @@ import {
TomlFileConflictError, TomlFileConflictError,
TomlFileValidationError, TomlFileValidationError,
probeTomlObjectFile, probeTomlObjectFile,
stringifyTomlObject,
writeTomlFileAtomic, writeTomlFileAtomic,
} from './compatible-cli-toml-file-service'; } from './compatible-cli-toml-file-service';
import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry';
@@ -44,6 +47,32 @@ export {
TomlFileValidationError as CodexRawConfigValidationError, TomlFileValidationError as CodexRawConfigValidationError,
}; };
const KNOWN_CODEX_FEATURES = new Set([
'apps',
'apply_patch_freeform',
'codex_hooks',
'fast_mode',
'js_repl',
'multi_agent',
'personality',
'prevent_idle_sleep',
'runtime_metrics',
'shell_snapshot',
'shell_tool',
'smart_approvals',
'unified_exec',
'undo',
'web_search',
'web_search_cached',
'web_search_request',
]);
const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']);
const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']);
const PERSONALITY_VALUES = new Set(['default', 'pragmatic', 'concise', 'direct']);
const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'ask']);
function isObject(value: unknown): value is Record<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);
} }
@@ -60,10 +89,409 @@ function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null; return typeof value === 'number' && Number.isFinite(value) ? value : null;
} }
function hasOwn(obj: Record<string, unknown>, key: string): boolean { function hasOwn(obj: object, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key); return Object.prototype.hasOwnProperty.call(obj, key);
} }
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asObject(target[key]);
if (existing) return existing;
const next: Record<string, unknown> = {};
target[key] = next;
return next;
}
function deleteIfEmpty(target: Record<string, unknown>, key: string) {
const value = asObject(target[key]);
if (value && Object.keys(value).length === 0) {
delete target[key];
}
}
function setStringField(target: Record<string, unknown>, key: string, value: unknown) {
if (!isNonEmptyString(value)) {
delete target[key];
return;
}
target[key] = value.trim();
}
function setEnumStringField(
target: Record<string, unknown>,
key: string,
value: unknown,
allowedValues: Set<string>,
label: string
) {
if (!isNonEmptyString(value)) {
delete target[key];
return;
}
const normalized = value.trim();
const currentValue = asString(target[key]);
if (!allowedValues.has(normalized) && normalized !== currentValue) {
throw new TomlFileValidationError(
`${label} must be one of: ${Array.from(allowedValues).join(', ')}.`
);
}
target[key] = normalized;
}
function setBooleanField(target: Record<string, unknown>, key: string, value: unknown) {
if (typeof value !== 'boolean') {
delete target[key];
return;
}
target[key] = value;
}
function setNumberField(
target: Record<string, unknown>,
key: string,
value: unknown,
options: { integer?: boolean; min?: number } = {}
) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
delete target[key];
return;
}
if (options.integer && !Number.isInteger(value)) {
throw new TomlFileValidationError(`${key} must be an integer.`);
}
if (typeof options.min === 'number' && value < options.min) {
throw new TomlFileValidationError(`${key} must be >= ${options.min}.`);
}
target[key] = value;
}
function normalizeStringArray(value: unknown, label: string): string[] | null {
if (value === null || value === undefined) return null;
if (!Array.isArray(value)) {
throw new TomlFileValidationError(`${label} must be an array of strings.`);
}
const normalized = value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
return normalized.length > 0 ? normalized : [];
}
function assertPatchableToml(fileProbe: {
diagnostics: { parseError: string | null; readError: string | null };
config: Record<string, unknown> | null;
}): Record<string, unknown> {
if (fileProbe.diagnostics.readError) {
throw new TomlFileValidationError(fileProbe.diagnostics.readError);
}
if (fileProbe.diagnostics.parseError) {
throw new TomlFileValidationError(
'config.toml contains invalid TOML. Fix the raw file before using guided controls.'
);
}
return asObject(fileProbe.config) ?? {};
}
function applyTopLevelSettingsPatch(
target: Record<string, unknown>,
values: Extract<CodexConfigPatchInput, { kind: 'top-level' }>['values']
) {
if (hasOwn(values, 'model')) setStringField(target, 'model', values.model);
if (hasOwn(values, 'modelReasoningEffort')) {
setEnumStringField(
target,
'model_reasoning_effort',
values.modelReasoningEffort,
MODEL_REASONING_EFFORT_VALUES,
'model_reasoning_effort'
);
}
if (hasOwn(values, 'modelProvider')) {
setStringField(target, 'model_provider', values.modelProvider);
}
if (hasOwn(values, 'approvalPolicy')) {
setEnumStringField(
target,
'approval_policy',
values.approvalPolicy,
APPROVAL_POLICY_VALUES,
'approval_policy'
);
}
if (hasOwn(values, 'sandboxMode')) {
setEnumStringField(
target,
'sandbox_mode',
values.sandboxMode,
SANDBOX_MODE_VALUES,
'sandbox_mode'
);
}
if (hasOwn(values, 'webSearch')) {
setEnumStringField(target, 'web_search', values.webSearch, WEB_SEARCH_VALUES, 'web_search');
}
if (hasOwn(values, 'toolOutputTokenLimit')) {
setNumberField(target, 'tool_output_token_limit', values.toolOutputTokenLimit, {
integer: true,
min: 1,
});
}
if (hasOwn(values, 'personality')) {
setEnumStringField(
target,
'personality',
values.personality,
PERSONALITY_VALUES,
'personality'
);
}
}
function applyProjectTrustPatch(
target: Record<string, unknown>,
input: Extract<CodexConfigPatchInput, { kind: 'project-trust' }>
) {
if (!isNonEmptyString(input.path)) {
throw new TomlFileValidationError('Project path is required.');
}
const expandedPath = expandPath(input.path.trim());
if (!path.isAbsolute(expandedPath)) {
throw new TomlFileValidationError('Project path must be absolute or use ~/... expansion.');
}
const canonicalPath = path.resolve(expandedPath);
const projects = ensureObject(target, 'projects');
if (!isNonEmptyString(input.trustLevel)) {
delete projects[canonicalPath];
deleteIfEmpty(target, 'projects');
return;
}
const trustLevel = input.trustLevel.trim();
if (!PROJECT_TRUST_LEVEL_VALUES.has(trustLevel)) {
throw new TomlFileValidationError(
`trust_level must be one of: ${Array.from(PROJECT_TRUST_LEVEL_VALUES).join(', ')}.`
);
}
projects[canonicalPath] = {
...(asObject(projects[canonicalPath]) ?? {}),
trust_level: trustLevel,
};
}
function applyFeaturePatch(
target: Record<string, unknown>,
input: Extract<CodexConfigPatchInput, { kind: 'feature' }>
) {
const feature = input.feature.trim();
const currentFeatures = asObject(target.features);
if (
!feature ||
(!KNOWN_CODEX_FEATURES.has(feature) && !(currentFeatures && hasOwn(currentFeatures, feature)))
) {
throw new TomlFileValidationError(`Unsupported feature key "${input.feature}".`);
}
if (input.enabled !== null && typeof input.enabled !== 'boolean') {
throw new TomlFileValidationError('Feature enabled must be boolean or null.');
}
const features = ensureObject(target, 'features');
if (input.enabled === null) {
delete features[feature];
} else {
features[feature] = input.enabled;
}
deleteIfEmpty(target, 'features');
}
function applyProfilePatch(
target: Record<string, unknown>,
input: Extract<CodexConfigPatchInput, { kind: 'profile' }>
) {
if (!isNonEmptyString(input.name)) {
throw new TomlFileValidationError('Profile name is required.');
}
const profileName = input.name.trim();
if (!['set-active', 'upsert', 'delete'].includes(input.action)) {
throw new TomlFileValidationError('Unsupported profile action.');
}
if (input.action === 'set-active') {
setStringField(target, 'profile', profileName);
return;
}
const profiles = ensureObject(target, 'profiles');
if (input.action === 'delete') {
delete profiles[profileName];
if (asString(target.profile) === profileName) {
delete target.profile;
}
deleteIfEmpty(target, 'profiles');
return;
}
const nextProfile = { ...(asObject(profiles[profileName]) ?? {}) };
applyTopLevelSettingsPatch(nextProfile, input.values ?? {});
if (Object.keys(nextProfile).length === 0) {
throw new TomlFileValidationError('Profile patch must include at least one saved field.');
}
profiles[profileName] = nextProfile;
if (input.setAsActive === true) {
target.profile = profileName;
}
}
function applyModelProviderPatch(
target: Record<string, unknown>,
input: Extract<CodexConfigPatchInput, { kind: 'model-provider' }>
) {
if (!isNonEmptyString(input.name)) {
throw new TomlFileValidationError('Model provider name is required.');
}
const providerName = input.name.trim();
const providers = ensureObject(target, 'model_providers');
if (!['upsert', 'delete'].includes(input.action)) {
throw new TomlFileValidationError('Unsupported model provider action.');
}
if (input.action === 'delete') {
delete providers[providerName];
if (asString(target.model_provider) === providerName) {
delete target.model_provider;
}
deleteIfEmpty(target, 'model_providers');
return;
}
const values = input.values;
if (!values) {
throw new TomlFileValidationError('Model provider values are required.');
}
const nextProvider = { ...(asObject(providers[providerName]) ?? {}) };
if (hasOwn(values, 'displayName')) setStringField(nextProvider, 'name', values.displayName);
if (hasOwn(values, 'baseUrl')) setStringField(nextProvider, 'base_url', values.baseUrl);
if (hasOwn(values, 'envKey')) setStringField(nextProvider, 'env_key', values.envKey);
if (hasOwn(values, 'wireApi')) {
if (values.wireApi !== null && values.wireApi !== undefined && values.wireApi !== 'responses') {
throw new TomlFileValidationError('wire_api must be "responses" for Codex model providers.');
}
setStringField(nextProvider, 'wire_api', values.wireApi);
}
if (hasOwn(values, 'requiresOpenaiAuth')) {
setBooleanField(nextProvider, 'requires_openai_auth', values.requiresOpenaiAuth);
}
if (hasOwn(values, 'supportsWebsockets')) {
setBooleanField(nextProvider, 'supports_websockets', values.supportsWebsockets);
}
if (Object.keys(nextProvider).length === 0) {
throw new TomlFileValidationError(
'Model provider patch must include at least one saved field.'
);
}
providers[providerName] = nextProvider;
}
function applyMcpServerPatch(
target: Record<string, unknown>,
input: Extract<CodexConfigPatchInput, { kind: 'mcp-server' }>
) {
if (!isNonEmptyString(input.name)) {
throw new TomlFileValidationError('MCP server name is required.');
}
const serverName = input.name.trim();
const servers = ensureObject(target, 'mcp_servers');
if (!['upsert', 'delete'].includes(input.action)) {
throw new TomlFileValidationError('Unsupported MCP server action.');
}
if (input.action === 'delete') {
delete servers[serverName];
deleteIfEmpty(target, 'mcp_servers');
return;
}
const values = input.values;
if (!values) {
throw new TomlFileValidationError('MCP server values are required.');
}
if (values.transport !== 'stdio' && values.transport !== 'streamable-http') {
throw new TomlFileValidationError('MCP transport must be "stdio" or "streamable-http".');
}
const nextServer = { ...(asObject(servers[serverName]) ?? {}) };
if (values.transport === 'stdio') {
if (!isNonEmptyString(values.command)) {
throw new TomlFileValidationError('Stdio MCP servers require a command.');
}
nextServer.command = values.command.trim();
const nextArgs = normalizeStringArray(values.args, 'args');
if (nextArgs === null) {
delete nextServer.args;
} else {
nextServer.args = nextArgs;
}
delete nextServer.url;
} else {
if (!isNonEmptyString(values.url)) {
throw new TomlFileValidationError('HTTP MCP servers require a URL.');
}
nextServer.url = values.url.trim();
delete nextServer.command;
delete nextServer.args;
}
if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled);
if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required);
if (hasOwn(values, 'startupTimeoutSec')) {
setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, {
integer: true,
min: 1,
});
}
if (hasOwn(values, 'toolTimeoutSec')) {
setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, {
integer: true,
min: 1,
});
}
if (hasOwn(values, 'enabledTools')) {
const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools');
if (nextEnabledTools === null) {
delete nextServer.enabled_tools;
} else {
nextServer.enabled_tools = nextEnabledTools;
}
}
if (hasOwn(values, 'disabledTools')) {
const nextDisabledTools = normalizeStringArray(values.disabledTools, 'disabledTools');
if (nextDisabledTools === null) {
delete nextServer.disabled_tools;
} else {
nextServer.disabled_tools = nextDisabledTools;
}
}
servers[serverName] = nextServer;
}
function parseTransport(server: Record<string, unknown>): CodexMcpServerDiagnostics['transport'] { function parseTransport(server: Record<string, unknown>): CodexMcpServerDiagnostics['transport'] {
if (asString(server.command)) return 'stdio'; if (asString(server.command)) return 'stdio';
if (asString(server.url)) return 'streamable-http'; if (asString(server.url)) return 'streamable-http';
@@ -294,13 +722,17 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo), supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo),
}, },
file: fileProbe.diagnostics, file: fileProbe.diagnostics,
workspacePath: process.cwd(),
config: { config: {
model: asString(config?.model), model: asString(config?.model),
modelReasoningEffort: asString(config?.model_reasoning_effort),
modelProvider: asString(config?.model_provider), modelProvider: asString(config?.model_provider),
activeProfile, activeProfile,
approvalPolicy: asString(config?.approval_policy), approvalPolicy: asString(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),
personality: asString(config?.personality),
topLevelKeys, topLevelKeys,
profileCount: profileNames.length, profileCount: profileNames.length,
profileNames, profileNames,
@@ -357,3 +789,57 @@ export async function saveCodexRawConfig(
return { success: true, mtime: saved.mtime }; return { success: true, mtime: saved.mtime };
} }
export async function patchCodexConfig(
input: CodexConfigPatchInput
): Promise<CodexConfigPatchResult> {
const paths = resolveCodexConfigPaths();
const fileProbe = await probeTomlObjectFile(
paths.configPath,
'Codex user config',
paths.configDisplayPath
);
const nextConfig = { ...assertPatchableToml(fileProbe) };
switch (input.kind) {
case 'top-level':
applyTopLevelSettingsPatch(nextConfig, input.values);
break;
case 'project-trust':
applyProjectTrustPatch(nextConfig, input);
break;
case 'feature':
applyFeaturePatch(nextConfig, input);
break;
case 'profile':
applyProfilePatch(nextConfig, input);
break;
case 'model-provider':
applyModelProviderPatch(nextConfig, input);
break;
case 'mcp-server':
applyMcpServerPatch(nextConfig, input);
break;
default:
throw new TomlFileValidationError('Unsupported Codex config patch.');
}
const rawText = stringifyTomlObject(nextConfig);
const saved = await writeTomlFileAtomic({
filePath: paths.configPath,
rawText,
expectedMtime: input.expectedMtime,
fileLabel: 'config.toml',
});
return {
success: true,
path: paths.configDisplayPath,
resolvedPath: paths.configPath,
exists: true,
mtime: saved.mtime,
rawText,
config: nextConfig,
parseError: null,
};
}
@@ -1,6 +1,6 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { parse } from 'smol-toml'; import { parse, stringify } from 'smol-toml';
export interface TomlFileDiagnostics { export interface TomlFileDiagnostics {
label: string; label: string;
@@ -92,6 +92,15 @@ export function parseTomlObjectText(
return parsed; return parsed;
} }
export function stringifyTomlObject(config: Record<string, unknown>): string {
if (!isObject(config)) {
throw new TomlFileValidationError('config TOML root must be a table.');
}
const text = stringify(config).trimEnd();
return text ? `${text}\n` : '';
}
export async function probeTomlObjectFile( export async function probeTomlObjectFile(
filePath: string, filePath: string,
label: string, label: string,
@@ -146,11 +146,14 @@ export interface CodexSupportMatrixEntry {
export interface CodexUserConfigDiagnostics { export interface CodexUserConfigDiagnostics {
model: string | null; model: string | null;
modelReasoningEffort: string | null;
modelProvider: string | null; modelProvider: string | null;
activeProfile: string | null; activeProfile: string | null;
approvalPolicy: string | null; approvalPolicy: string | null;
sandboxMode: string | null; sandboxMode: string | null;
webSearch: string | null; webSearch: string | null;
toolOutputTokenLimit: number | null;
personality: string | null;
topLevelKeys: string[]; topLevelKeys: string[];
profileCount: number; profileCount: number;
profileNames: string[]; profileNames: string[];
@@ -169,6 +172,7 @@ export interface CodexUserConfigDiagnostics {
export interface CodexDashboardDiagnostics { export interface CodexDashboardDiagnostics {
binary: CodexBinaryDiagnostics; binary: CodexBinaryDiagnostics;
file: CodexConfigFileDiagnostics; file: CodexConfigFileDiagnostics;
workspacePath: string;
config: CodexUserConfigDiagnostics; config: CodexUserConfigDiagnostics;
supportMatrix: CodexSupportMatrixEntry[]; supportMatrix: CodexSupportMatrixEntry[];
warnings: string[]; warnings: string[];
@@ -184,3 +188,83 @@ export interface CodexRawConfigResponse {
config: Record<string, unknown> | null; config: Record<string, unknown> | null;
parseError: string | null; parseError: string | null;
} }
export interface CodexTopLevelSettingsPatch {
model?: string | null;
modelReasoningEffort?: string | null;
modelProvider?: string | null;
approvalPolicy?: string | null;
sandboxMode?: string | null;
webSearch?: string | null;
toolOutputTokenLimit?: number | null;
personality?: string | null;
}
export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {}
export interface CodexModelProviderPatchValues {
displayName?: string | null;
baseUrl?: string | null;
envKey?: string | null;
wireApi?: string | null;
requiresOpenaiAuth?: boolean | null;
supportsWebsockets?: boolean | null;
}
export interface CodexMcpServerPatchValues {
transport: 'stdio' | 'streamable-http';
command?: string | null;
args?: string[] | null;
url?: string | null;
enabled?: boolean | null;
required?: boolean | null;
startupTimeoutSec?: number | null;
toolTimeoutSec?: number | null;
enabledTools?: string[] | null;
disabledTools?: string[] | null;
}
export type CodexConfigPatchInput =
| {
kind: 'top-level';
expectedMtime?: number;
values: CodexTopLevelSettingsPatch;
}
| {
kind: 'project-trust';
expectedMtime?: number;
path: string;
trustLevel: string | null;
}
| {
kind: 'feature';
expectedMtime?: number;
feature: string;
enabled: boolean | null;
}
| {
kind: 'profile';
expectedMtime?: number;
action: 'set-active' | 'upsert' | 'delete';
name: string;
values?: CodexProfilePatchValues;
setAsActive?: boolean;
}
| {
kind: 'model-provider';
expectedMtime?: number;
action: 'upsert' | 'delete';
name: string;
values?: CodexModelProviderPatchValues;
}
| {
kind: 'mcp-server';
expectedMtime?: number;
action: 'upsert' | 'delete';
name: string;
values?: CodexMcpServerPatchValues;
};
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
success: true;
}
@@ -7,6 +7,7 @@ import {
CodexRawConfigValidationError, CodexRawConfigValidationError,
getCodexDashboardDiagnostics, getCodexDashboardDiagnostics,
getCodexRawConfig, getCodexRawConfig,
patchCodexConfig,
resolveCodexConfigPaths, resolveCodexConfigPaths,
saveCodexRawConfig, saveCodexRawConfig,
summarizeCodexFeatureFlags, summarizeCodexFeatureFlags,
@@ -262,4 +263,172 @@ bearer_token = "secret"
}) })
).rejects.toThrow(CodexRawConfigConflictError); ).rejects.toThrow(CodexRawConfigConflictError);
}); });
it('patches top-level settings and project trust through structured controls', async () => {
const result = await patchCodexConfig({
kind: 'top-level',
values: {
model: 'gpt-5.4',
modelReasoningEffort: 'high',
approvalPolicy: 'never',
sandboxMode: 'workspace-write',
webSearch: 'cached',
toolOutputTokenLimit: 12000,
personality: 'pragmatic',
},
});
await patchCodexConfig({
kind: 'project-trust',
path: '/tmp/workspace-a',
trustLevel: 'trusted',
expectedMtime: result.mtime,
});
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.config.model).toBe('gpt-5.4');
expect(diagnostics.config.modelReasoningEffort).toBe('high');
expect(diagnostics.config.toolOutputTokenLimit).toBe(12000);
expect(diagnostics.config.personality).toBe('pragmatic');
expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a');
expect(result.rawText).toContain('model = "gpt-5.4"');
expect(result.config?.model).toBe('gpt-5.4');
});
it('expands home paths for project trust and rejects relative paths', async () => {
const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace');
const expanded = await patchCodexConfig({
kind: 'project-trust',
path: '~/codex-workspace',
trustLevel: 'trusted',
});
expect(expanded.rawText).toContain(`[projects."${homeWorkspacePath}"]`);
await expect(
patchCodexConfig({
kind: 'project-trust',
path: './relative-workspace',
trustLevel: 'trusted',
})
).rejects.toThrow(CodexRawConfigValidationError);
});
it('patches profiles, providers, and mcp servers through structured controls', async () => {
const providerResult = await patchCodexConfig({
kind: 'model-provider',
action: 'upsert',
name: 'cliproxy',
values: {
displayName: 'CLIProxy',
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
envKey: 'CLIPROXY_API_KEY',
wireApi: 'responses',
},
});
const profileResult = await patchCodexConfig({
kind: 'profile',
action: 'upsert',
name: 'deep-review',
values: {
model: 'gpt-5.4',
modelProvider: 'cliproxy',
modelReasoningEffort: 'xhigh',
},
setAsActive: true,
expectedMtime: providerResult.mtime,
});
await patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'playwright',
values: {
transport: 'stdio',
command: 'npx',
args: ['@playwright/mcp@latest'],
enabled: true,
required: false,
startupTimeoutSec: 15,
toolTimeoutSec: 30,
},
expectedMtime: profileResult.mtime,
});
const diagnostics = await getCodexDashboardDiagnostics();
expect(diagnostics.config.activeProfile).toBe('deep-review');
expect(diagnostics.config.modelProviderCount).toBe(1);
expect(diagnostics.config.mcpServerCount).toBe(1);
const raw = await getCodexRawConfig();
expect(raw.rawText).toContain('[profiles.deep-review]');
expect(raw.rawText).toContain('[model_providers.cliproxy]');
expect(raw.rawText).toContain('[mcp_servers.playwright]');
expect(profileResult.rawText).toContain('[profiles.deep-review]');
expect(profileResult.config?.profile).toBe('deep-review');
});
it('rejects structured patches when config.toml is invalid', async () => {
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
await expect(
patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
})
).rejects.toThrow(CodexRawConfigValidationError);
});
it('removes feature overrides when a feature is reset to inherited state', async () => {
const enabled = await patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
});
expect(enabled.rawText).toContain('[features]');
expect(enabled.rawText).toContain('multi_agent = true');
const reset = await patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: null,
expectedMtime: enabled.mtime,
});
expect(reset.rawText).not.toContain('[features]');
expect(reset.config?.features).toBeUndefined();
});
it('rejects malformed structured patch payloads at runtime', async () => {
await expect(
patchCodexConfig({
kind: 'feature',
feature: 'multi_agent',
enabled: 'true' as unknown as boolean | null,
})
).rejects.toThrow(CodexRawConfigValidationError);
await expect(
patchCodexConfig({
kind: 'project-trust',
path: '~/codex-workspace',
trustLevel: 'always',
})
).rejects.toThrow(CodexRawConfigValidationError);
await expect(
patchCodexConfig({
kind: 'mcp-server',
action: 'upsert',
name: 'remote',
values: {
transport: 'http' as 'stdio' | 'streamable-http',
url: 'https://example.test/mcp',
},
})
).rejects.toThrow(CodexRawConfigValidationError);
});
}); });
+142
View File
@@ -0,0 +1,142 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
let server: Server;
let baseUrl = '';
let tempDir = '';
let codexHome = '';
let originalCodexHome: string | undefined;
beforeAll(async () => {
originalCodexHome = process.env.CODEX_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-routes-test-'));
codexHome = path.join(tempDir, '.codex-home');
process.env.CODEX_HOME = codexHome;
const codexRoutesModule = await import('../../../src/web-server/routes/codex-routes');
const app = express();
app.use(express.json());
app.use('/api/codex', codexRoutesModule.default);
server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.on('listening', () => resolve()));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
beforeEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.mkdirSync(codexHome, { recursive: true });
});
afterAll(async () => {
if (server) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
if (originalCodexHome !== undefined) {
process.env.CODEX_HOME = originalCodexHome;
} else {
delete process.env.CODEX_HOME;
}
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('codex routes', () => {
it('returns the current raw config snapshot from PATCH /config/patch', async () => {
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'top-level',
values: {
model: 'gpt-5.4',
sandboxMode: 'workspace-write',
},
}),
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
success: boolean;
exists: boolean;
mtime: number;
rawText: string;
config: Record<string, unknown> | null;
parseError: string | null;
};
expect(json.success).toBe(true);
expect(json.exists).toBe(true);
expect(json.mtime).toBeGreaterThan(0);
expect(json.parseError).toBeNull();
expect(json.rawText).toContain('model = "gpt-5.4"');
expect(json.rawText).toContain('sandbox_mode = "workspace-write"');
expect(json.config?.model).toBe('gpt-5.4');
const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
expect(written).toBe(json.rawText);
});
it('returns 409 when PATCH /config/patch receives a stale expectedMtime', async () => {
const configPath = path.join(codexHome, 'config.toml');
fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n');
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'feature',
feature: 'multi_agent',
enabled: true,
expectedMtime: 1,
}),
});
expect(res.status).toBe(409);
const json = (await res.json()) as { error: string; mtime: number };
expect(json.error).toContain('File modified externally.');
expect(json.mtime).toBeGreaterThan(0);
});
it('returns 400 when PATCH /config/patch omits kind', async () => {
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
expect(json.error).toBe('kind is required.');
});
it('returns 400 when PATCH /config/patch receives an invalid trust path', async () => {
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'project-trust',
path: './relative-workspace',
trustLevel: 'trusted',
}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
expect(json.error).toContain('Project path must be absolute');
});
});
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface CodexConfigCardShellProps {
title: string;
icon?: ReactNode;
badge?: string;
description?: string;
disabledReason?: string | null;
children: ReactNode;
}
export function CodexConfigCardShell({
title,
icon,
badge,
description,
disabledReason,
children,
}: CodexConfigCardShellProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
{icon}
{title}
{badge ? (
<Badge variant="outline" className="text-[10px] font-normal">
{badge}
</Badge>
) : null}
</CardTitle>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</CardHeader>
<CardContent className="space-y-3">
{disabledReason ? <p className="text-xs text-amber-600">{disabledReason}</p> : null}
{children}
</CardContent>
</Card>
);
}
@@ -0,0 +1,129 @@
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import type { CodexFeatureCatalogEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexFeaturesCardProps {
catalog: CodexFeatureCatalogEntry[];
state: Record<string, boolean | null>;
disabled?: boolean;
disabledReason?: string | null;
onToggle: (feature: string, enabled: boolean | null) => Promise<void> | void;
}
export function CodexFeaturesCard({
catalog,
state,
disabled = false,
disabledReason,
onToggle,
}: CodexFeaturesCardProps) {
const knownFeatureNames = new Set(catalog.map((feature) => feature.name));
const configOnlyFeatures = Object.entries(state)
.filter(([name]) => !knownFeatureNames.has(name))
.sort(([left], [right]) => left.localeCompare(right));
return (
<CodexConfigCardShell
title="Features"
badge="features"
icon={<Sparkles className="h-4 w-4" />}
description="Toggle the supported Codex feature flags CCS can safely manage."
disabledReason={disabledReason}
>
<div className="space-y-2">
{catalog.map((feature) => {
const current = state[feature.name] ?? null;
return (
<div
key={feature.name}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{feature.label}</p>
<Badge variant="outline" className="font-mono text-[10px]">
{feature.name}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{feature.description}</p>
</div>
<div className="flex items-center gap-2">
{current !== null ? (
<Button
variant="outline"
size="sm"
onClick={() => onToggle(feature.name, null)}
disabled={disabled}
>
Use default
</Button>
) : null}
<Switch
checked={current === true}
onCheckedChange={(next) => onToggle(feature.name, next)}
disabled={disabled}
/>
</div>
</div>
);
})}
</div>
{configOnlyFeatures.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Existing config-only flags
</p>
<p className="text-xs text-muted-foreground">
These feature keys already exist in your `config.toml`, so CCS can surface them
without claiming full catalog coverage.
</p>
</div>
{configOnlyFeatures.map(([name, current]) => (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{name}</p>
<Badge variant="secondary" className="text-[10px]">
existing
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{current === null
? 'Stored in a non-boolean form. Use raw TOML if you need to edit it.'
: "Discovered from the current file instead of CCS's built-in catalog."}
</p>
</div>
{current === null ? (
<Badge variant="outline">Raw only</Badge>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onToggle(name, null)}
disabled={disabled}
>
Use default
</Button>
<Switch
checked={current === true}
onCheckedChange={(next) => onToggle(name, next)}
disabled={disabled}
/>
</div>
)}
</div>
))}
</div>
) : null}
</CodexConfigCardShell>
);
}
@@ -0,0 +1,278 @@
import { useMemo, useState } from 'react';
import { Loader2, PlugZap, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexMcpServerPatchValues } from '@/hooks/use-codex-types';
import type { CodexMcpServerEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexMcpServersCardProps {
entries: CodexMcpServerEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
}
const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = {
name: '',
transport: 'stdio',
command: null,
args: [],
url: null,
enabled: true,
required: false,
startupTimeoutSec: null,
toolTimeoutSec: null,
enabledTools: [],
disabledTools: [],
};
function toCsv(value: string[]) {
return value.join(', ');
}
function fromCsv(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
interface McpServerEditorProps {
initialDraft: CodexMcpServerEntry;
isNew: boolean;
disabled: boolean;
saving: boolean;
canDelete: boolean;
onSave: (name: string, values: CodexMcpServerPatchValues) => Promise<void> | void;
onDelete: () => Promise<void> | void;
}
function McpServerEditor({
initialDraft,
isNew,
disabled,
saving,
canDelete,
onSave,
onDelete,
}: McpServerEditorProps) {
const [draft, setDraft] = useState<CodexMcpServerEntry>(initialDraft);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
placeholder="playwright"
disabled={disabled || !isNew}
/>
<Select
value={draft.transport}
onValueChange={(next) =>
setDraft((current) => ({
...current,
transport: next as CodexMcpServerEntry['transport'],
}))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="streamable-http">streamable-http</SelectItem>
</SelectContent>
</Select>
{draft.transport === 'stdio' ? (
<>
<Input
value={draft.command ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, command: event.target.value || null }))
}
placeholder="npx"
disabled={disabled}
/>
<Input
value={toCsv(draft.args)}
onChange={(event) =>
setDraft((current) => ({ ...current, args: fromCsv(event.target.value) }))
}
placeholder="@playwright/mcp@latest, --flag"
disabled={disabled}
/>
</>
) : (
<Input
className="sm:col-span-2"
value={draft.url ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, url: event.target.value || null }))
}
placeholder="https://example.test/mcp"
disabled={disabled}
/>
)}
<Input
type="number"
min={1}
value={draft.startupTimeoutSec ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
startupTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="Startup timeout (sec)"
disabled={disabled}
/>
<Input
type="number"
min={1}
value={draft.toolTimeoutSec ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
toolTimeoutSec: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="Tool timeout (sec)"
disabled={disabled}
/>
<Input
value={toCsv(draft.enabledTools)}
onChange={(event) =>
setDraft((current) => ({ ...current, enabledTools: fromCsv(event.target.value) }))
}
placeholder="enabled_tools"
disabled={disabled}
/>
<Input
value={toCsv(draft.disabledTools)}
onChange={(event) =>
setDraft((current) => ({ ...current, disabledTools: fromCsv(event.target.value) }))
}
placeholder="disabled_tools"
disabled={disabled}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Enabled
<Switch
checked={draft.enabled}
onCheckedChange={(next) => setDraft((current) => ({ ...current, enabled: next }))}
disabled={disabled}
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Required
<Switch
checked={draft.required}
onCheckedChange={(next) => setDraft((current) => ({ ...current, required: next }))}
disabled={disabled}
/>
</label>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
onClick={() =>
onSave(draft.name, {
transport: draft.transport,
command: draft.command,
args: draft.args,
url: draft.url,
enabled: draft.enabled,
required: draft.required,
startupTimeoutSec: draft.startupTimeoutSec,
toolTimeoutSec: draft.toolTimeoutSec,
enabledTools: draft.enabledTools,
disabledTools: draft.disabledTools,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save MCP server
</Button>
</div>
</>
);
}
export function CodexMcpServersCard({
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
}: CodexMcpServersCardProps) {
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
return (
<CodexConfigCardShell
title="MCP servers"
badge="mcp_servers"
icon={<PlugZap className="h-4 w-4" />}
description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select MCP server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new MCP server</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
<McpServerEditor
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
saving={saving}
canDelete={selectedEntry !== null}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSave={async (name, values) => {
await onSave(name, values);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,209 @@
import { useMemo, useState } from 'react';
import { KeyRound, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexModelProviderPatchValues } from '@/hooks/use-codex-types';
import type { CodexModelProviderEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexModelProvidersCardProps {
entries: CodexModelProviderEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
}
const EMPTY_MODEL_PROVIDER_DRAFT: CodexModelProviderEntry = {
name: '',
displayName: null,
baseUrl: null,
envKey: null,
wireApi: 'responses',
requiresOpenaiAuth: false,
supportsWebsockets: false,
};
interface ModelProviderEditorProps {
initialDraft: CodexModelProviderEntry;
isNew: boolean;
disabled: boolean;
saving: boolean;
canDelete: boolean;
onSave: (name: string, values: CodexModelProviderPatchValues) => Promise<void> | void;
onDelete: () => Promise<void> | void;
}
function ModelProviderEditor({
initialDraft,
isNew,
disabled,
saving,
canDelete,
onSave,
onDelete,
}: ModelProviderEditorProps) {
const [draft, setDraft] = useState<CodexModelProviderEntry>(initialDraft);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
placeholder="Provider id"
disabled={disabled || !isNew}
/>
<Input
value={draft.displayName ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, displayName: event.target.value || null }))
}
placeholder="Display name"
disabled={disabled}
/>
<Input
value={draft.baseUrl ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, baseUrl: event.target.value || null }))
}
placeholder="http://127.0.0.1:8317/api/provider/codex"
disabled={disabled}
/>
<Input
value={draft.envKey ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, envKey: event.target.value || null }))
}
placeholder="CLIPROXY_API_KEY"
disabled={disabled}
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Select
value={draft.wireApi ?? 'responses'}
onValueChange={(next) => setDraft((current) => ({ ...current, wireApi: next }))}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="responses">responses</SelectItem>
</SelectContent>
</Select>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Requires OpenAI auth
<Switch
checked={draft.requiresOpenaiAuth}
onCheckedChange={(next) =>
setDraft((current) => ({ ...current, requiresOpenaiAuth: next }))
}
disabled={disabled}
/>
</label>
<label className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
Supports websockets
<Switch
checked={draft.supportsWebsockets}
onCheckedChange={(next) =>
setDraft((current) => ({ ...current, supportsWebsockets: next }))
}
disabled={disabled}
/>
</label>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={onDelete} disabled={disabled || saving || !canDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
onClick={() =>
onSave(draft.name, {
displayName: draft.displayName,
baseUrl: draft.baseUrl,
envKey: draft.envKey,
wireApi: draft.wireApi,
requiresOpenaiAuth: draft.requiresOpenaiAuth,
supportsWebsockets: draft.supportsWebsockets,
})
}
disabled={disabled || saving || draft.name.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save provider
</Button>
</div>
</>
);
}
export function CodexModelProvidersCard({
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
}: CodexModelProvidersCardProps) {
const [selectedName, setSelectedName] = useState<string>('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftSeed = selectedEntry ?? EMPTY_MODEL_PROVIDER_DRAFT;
const draftKey = JSON.stringify(draftSeed);
return (
<CodexConfigCardShell
title="Model providers"
badge="model_providers"
icon={<KeyRound className="h-4 w-4" />}
description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new provider</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ModelProviderEditor
key={draftKey}
initialDraft={draftSeed}
isNew={selectedName === 'new'}
disabled={disabled}
saving={saving}
canDelete={selectedEntry !== null}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSave={async (name, values) => {
await onSave(name, values);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,250 @@
import { useMemo, useState } from 'react';
import { Layers3, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexProfilePatchValues } from '@/hooks/use-codex-types';
import type { CodexProfileEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexProfilesCardProps {
activeProfile: string | null;
entries: CodexProfileEntry[];
providerNames: string[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (
name: string,
values: CodexProfilePatchValues,
setAsActive: boolean
) => Promise<void> | void;
onDelete: (name: string) => Promise<void> | void;
onSetActive: (name: string) => Promise<void> | void;
}
interface ProfileEditorProps {
initialName: string;
initialModel: string | null;
initialProvider: string | null;
initialEffort: string | null;
providerNames: string[];
activeProfile: string | null;
selectedEntryName: string | null;
disabled: boolean;
saving: boolean;
onSave: (
name: string,
values: CodexProfilePatchValues,
setAsActive: boolean
) => Promise<void> | void;
onDelete: () => Promise<void> | void;
onSetActive: () => Promise<void> | void;
}
function ProfileEditor({
initialName,
initialModel,
initialProvider,
initialEffort,
providerNames,
activeProfile,
selectedEntryName,
disabled,
saving,
onSave,
onDelete,
onSetActive,
}: ProfileEditorProps) {
const [nameDraft, setNameDraft] = useState(initialName);
const [modelDraft, setModelDraft] = useState<string | null>(initialModel);
const [providerDraft, setProviderDraft] = useState<string | null>(initialProvider);
const [effortDraft, setEffortDraft] = useState<string | null>(initialEffort);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<Input
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
placeholder="deep-review"
disabled={disabled || selectedEntryName !== null}
/>
<Input
value={modelDraft ?? ''}
onChange={(event) => setModelDraft(event.target.value || null)}
placeholder="gpt-5.4"
disabled={disabled}
/>
<Select
value={providerDraft ?? '__unset__'}
onValueChange={(next) => setProviderDraft(next === '__unset__' ? null : next)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global provider</SelectItem>
{providerNames.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={effortDraft ?? '__unset__'}
onValueChange={(next) => setEffortDraft(next === '__unset__' ? null : next)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use global effort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__">Use global effort</SelectItem>
{['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-between gap-2">
<div className="flex gap-2">
<Button
variant="outline"
onClick={onDelete}
disabled={disabled || saving || !selectedEntryName}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
<Button
variant="outline"
onClick={onSetActive}
disabled={
disabled || saving || !selectedEntryName || selectedEntryName === activeProfile
}
>
Set active
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() =>
onSave(
nameDraft,
{
model: modelDraft,
modelProvider: providerDraft,
modelReasoningEffort: effortDraft,
},
false
)
}
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save profile
</Button>
<Button
onClick={() =>
onSave(
nameDraft,
{
model: modelDraft,
modelProvider: providerDraft,
modelReasoningEffort: effortDraft,
},
true
)
}
disabled={disabled || saving || nameDraft.trim().length === 0}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save + activate
</Button>
</div>
</div>
</>
);
}
export function CodexProfilesCard({
activeProfile,
entries,
providerNames,
disabled = false,
disabledReason,
saving = false,
onSave,
onDelete,
onSetActive,
}: CodexProfilesCardProps) {
const [selectedName, setSelectedName] = useState('new');
const selectedEntry = useMemo(
() => entries.find((entry) => entry.name === selectedName) ?? null,
[entries, selectedName]
);
const draftKey = JSON.stringify(selectedEntry ?? { name: '', values: {} });
return (
<CodexConfigCardShell
title="Profiles"
badge="profiles"
icon={<Layers3 className="h-4 w-4" />}
description="Create reusable Codex overlays and set the active default profile."
disabledReason={disabledReason}
>
<Select value={selectedName} onValueChange={setSelectedName} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">Create new profile</SelectItem>
{entries.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.name}
{entry.name === activeProfile ? ' (active)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<ProfileEditor
key={draftKey}
initialName={selectedEntry?.name ?? ''}
initialModel={selectedEntry?.values.model ?? null}
initialProvider={selectedEntry?.values.modelProvider ?? null}
initialEffort={selectedEntry?.values.modelReasoningEffort ?? null}
providerNames={providerNames}
activeProfile={activeProfile}
selectedEntryName={selectedEntry?.name ?? null}
disabled={disabled}
saving={saving}
onDelete={async () => {
if (!selectedEntry) return;
await onDelete(selectedEntry.name);
setSelectedName('new');
}}
onSetActive={async () => {
if (!selectedEntry) return;
await onSetActive(selectedEntry.name);
}}
onSave={async (name, values, setAsActive) => {
await onSave(name, values, setAsActive);
setSelectedName(name);
}}
/>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { FolderCheck, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexProjectTrustEntry } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
interface CodexProjectTrustCardProps {
workspacePath: string;
entries: CodexProjectTrustEntry[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
}
interface ProjectTrustComposerProps {
workspacePath: string;
disabled: boolean;
saving: boolean;
onSave: (path: string, trustLevel: string | null) => Promise<void> | void;
}
function ProjectTrustComposer({
workspacePath,
disabled,
saving,
onSave,
}: ProjectTrustComposerProps) {
const [pathDraft, setPathDraft] = useState(workspacePath);
const [trustLevel, setTrustLevel] = useState('trusted');
return (
<div className="grid gap-2 sm:grid-cols-[1fr_160px_auto]">
<Input
value={pathDraft}
onChange={(event) => setPathDraft(event.target.value)}
placeholder="~/repo or /absolute/path"
disabled={disabled}
/>
<Select value={trustLevel} onValueChange={setTrustLevel} disabled={disabled}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trusted">trusted</SelectItem>
<SelectItem value="ask">ask</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => onSave(pathDraft, trustLevel)} disabled={disabled || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save trust
</Button>
</div>
);
}
export function CodexProjectTrustCard({
workspacePath,
entries,
disabled = false,
disabledReason,
saving = false,
onSave,
}: CodexProjectTrustCardProps) {
return (
<CodexConfigCardShell
title="Project trust"
badge="projects"
icon={<FolderCheck className="h-4 w-4" />}
description="Trust current workspaces or remove stale trust entries without opening raw TOML."
disabledReason={disabledReason}
>
<p className="text-xs text-muted-foreground">
Paths must be absolute or start with <code>~/</code>. Relative paths are rejected so CCS
does not trust the wrong folder.
</p>
<ProjectTrustComposer
key={workspacePath}
workspacePath={workspacePath}
disabled={disabled}
saving={saving}
onSave={onSave}
/>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => onSave(workspacePath, 'trusted')}
disabled={disabled || saving}
>
Trust current workspace
</Button>
<div className="space-y-2">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">No explicit project trust entries saved.</p>
) : (
entries.map((entry) => (
<div
key={entry.path}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{entry.path}</p>
<p className="text-xs text-muted-foreground">trust_level = {entry.trustLevel}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
onSave(entry.path, entry.trustLevel === 'trusted' ? 'ask' : 'trusted')
}
disabled={disabled || saving}
>
Toggle
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onSave(entry.path, null)}
disabled={disabled || saving}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</CodexConfigCardShell>
);
}
@@ -0,0 +1,280 @@
import { useState } from 'react';
import { Loader2, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { CodexTopLevelSettingsPatch } from '@/hooks/use-codex-types';
import type { CodexTopLevelSettingsView } from '@/lib/codex-config';
import { CodexConfigCardShell } from './codex-config-card-shell';
const UNSET = '__unset__';
interface CodexTopLevelControlsCardProps {
values: CodexTopLevelSettingsView;
providerNames: string[];
disabled?: boolean;
disabledReason?: string | null;
saving?: boolean;
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
}
function toSelectValue(value: string | null | undefined) {
return value ?? UNSET;
}
function withCurrentValue(options: string[], current: string | null | undefined) {
return current && !options.includes(current) ? [current, ...options] : options;
}
interface TopLevelControlsFormProps {
initialValues: CodexTopLevelSettingsView;
providerNames: string[];
disabled: boolean;
saving: boolean;
onSave: (values: CodexTopLevelSettingsPatch) => Promise<void> | void;
}
function TopLevelControlsForm({
initialValues,
providerNames,
disabled,
saving,
onSave,
}: TopLevelControlsFormProps) {
const [draft, setDraft] = useState<CodexTopLevelSettingsView>(initialValues);
const reasoningOptions = withCurrentValue(
['minimal', 'low', 'medium', 'high', 'xhigh'],
draft.modelReasoningEffort
);
const providerOptions = withCurrentValue(providerNames, draft.modelProvider);
const approvalOptions = withCurrentValue(
['on-request', 'never', 'untrusted'],
draft.approvalPolicy
);
const sandboxOptions = withCurrentValue(
['read-only', 'workspace-write', 'danger-full-access'],
draft.sandboxMode
);
const webSearchOptions = withCurrentValue(['cached', 'live', 'disabled'], draft.webSearch);
const personalityOptions = withCurrentValue(
['default', 'pragmatic', 'concise', 'direct'],
draft.personality
);
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium">Model</p>
<Input
value={draft.model ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, model: event.target.value || null }))
}
placeholder="gpt-5.4"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Reasoning effort</p>
<Select
value={toSelectValue(draft.modelReasoningEffort)}
onValueChange={(next) =>
setDraft((current) => ({
...current,
modelReasoningEffort: next === UNSET ? null : next,
}))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{reasoningOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Default provider</p>
<Select
value={toSelectValue(draft.modelProvider)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, modelProvider: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use Codex default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use Codex default</SelectItem>
{providerOptions.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Approval policy</p>
<Select
value={toSelectValue(draft.approvalPolicy)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, approvalPolicy: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{approvalOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Sandbox mode</p>
<Select
value={toSelectValue(draft.sandboxMode)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, sandboxMode: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{sandboxOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Web search</p>
<Select
value={toSelectValue(draft.webSearch)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, webSearch: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{webSearchOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Tool output token limit</p>
<Input
type="number"
min={1}
value={draft.toolOutputTokenLimit ?? ''}
onChange={(event) =>
setDraft((current) => ({
...current,
toolOutputTokenLimit: event.target.value ? Number(event.target.value) : null,
}))
}
placeholder="25000"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-xs font-medium">Personality</p>
<Select
value={toSelectValue(draft.personality)}
onValueChange={(next) =>
setDraft((current) => ({ ...current, personality: next === UNSET ? null : next }))
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Use default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Use default</SelectItem>
{personalityOptions.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button onClick={() => onSave(draft)} disabled={disabled || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save top-level settings
</Button>
</div>
</>
);
}
export function CodexTopLevelControlsCard({
values,
providerNames,
disabled = false,
disabledReason,
saving = false,
onSave,
}: CodexTopLevelControlsCardProps) {
return (
<CodexConfigCardShell
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."
disabledReason={disabledReason}
>
<TopLevelControlsForm
key={JSON.stringify(values)}
initialValues={values}
providerNames={providerNames}
disabled={disabled}
saving={saving}
onSave={onSave}
/>
</CodexConfigCardShell>
);
}
+84
View File
@@ -88,11 +88,14 @@ export interface CodexSupportMatrixEntry {
export interface CodexUserConfigDiagnostics { export interface CodexUserConfigDiagnostics {
model: string | null; model: string | null;
modelReasoningEffort: string | null;
modelProvider: string | null; modelProvider: string | null;
activeProfile: string | null; activeProfile: string | null;
approvalPolicy: string | null; approvalPolicy: string | null;
sandboxMode: string | null; sandboxMode: string | null;
webSearch: string | null; webSearch: string | null;
toolOutputTokenLimit: number | null;
personality: string | null;
topLevelKeys: string[]; topLevelKeys: string[];
profileCount: number; profileCount: number;
profileNames: string[]; profileNames: string[];
@@ -111,6 +114,7 @@ export interface CodexUserConfigDiagnostics {
export interface CodexDashboardDiagnostics { export interface CodexDashboardDiagnostics {
binary: CodexBinaryDiagnostics; binary: CodexBinaryDiagnostics;
file: CodexConfigFileDiagnostics; file: CodexConfigFileDiagnostics;
workspacePath: string;
config: CodexUserConfigDiagnostics; config: CodexUserConfigDiagnostics;
supportMatrix: CodexSupportMatrixEntry[]; supportMatrix: CodexSupportMatrixEntry[];
warnings: string[]; warnings: string[];
@@ -126,3 +130,83 @@ export interface CodexRawConfigResponse {
config: Record<string, unknown> | null; config: Record<string, unknown> | null;
parseError: string | null; parseError: string | null;
} }
export interface CodexTopLevelSettingsPatch {
model?: string | null;
modelReasoningEffort?: string | null;
modelProvider?: string | null;
approvalPolicy?: string | null;
sandboxMode?: string | null;
webSearch?: string | null;
toolOutputTokenLimit?: number | null;
personality?: string | null;
}
export type CodexProfilePatchValues = CodexTopLevelSettingsPatch;
export interface CodexModelProviderPatchValues {
displayName?: string | null;
baseUrl?: string | null;
envKey?: string | null;
wireApi?: string | null;
requiresOpenaiAuth?: boolean | null;
supportsWebsockets?: boolean | null;
}
export interface CodexMcpServerPatchValues {
transport: 'stdio' | 'streamable-http';
command?: string | null;
args?: string[] | null;
url?: string | null;
enabled?: boolean | null;
required?: boolean | null;
startupTimeoutSec?: number | null;
toolTimeoutSec?: number | null;
enabledTools?: string[] | null;
disabledTools?: string[] | null;
}
export type CodexConfigPatchInput =
| {
kind: 'top-level';
expectedMtime?: number;
values: CodexTopLevelSettingsPatch;
}
| {
kind: 'project-trust';
expectedMtime?: number;
path: string;
trustLevel: string | null;
}
| {
kind: 'feature';
expectedMtime?: number;
feature: string;
enabled: boolean | null;
}
| {
kind: 'profile';
expectedMtime?: number;
action: 'set-active' | 'upsert' | 'delete';
name: string;
values?: CodexProfilePatchValues;
setAsActive?: boolean;
}
| {
kind: 'model-provider';
expectedMtime?: number;
action: 'upsert' | 'delete';
name: string;
values?: CodexModelProviderPatchValues;
}
| {
kind: 'mcp-server';
expectedMtime?: number;
action: 'upsert' | 'delete';
name: string;
values?: CodexMcpServerPatchValues;
};
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
success: true;
}
+37 -1
View File
@@ -2,7 +2,12 @@ import { useMemo } from 'react';
import { parse as parseToml } from 'smol-toml'; import { parse as parseToml } from 'smol-toml';
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 { CodexDashboardDiagnostics, CodexRawConfigResponse } from './use-codex-types'; import type {
CodexConfigPatchInput,
CodexConfigPatchResult,
CodexDashboardDiagnostics,
CodexRawConfigResponse,
} from './use-codex-types';
type CodexRawConfig = CodexRawConfigResponse; type CodexRawConfig = CodexRawConfigResponse;
@@ -16,6 +21,8 @@ interface SaveCodexRawConfigResponse {
mtime: number; mtime: number;
} }
type PatchCodexConfigResponse = CodexConfigPatchResult;
function parseCodexRawConfigText(rawText: string): { function parseCodexRawConfigText(rawText: string): {
config: Record<string, unknown> | null; config: Record<string, unknown> | null;
parseError: string | null; parseError: string | null;
@@ -69,6 +76,21 @@ async function saveCodexRawConfig(
return res.json(); return res.json();
} }
async function patchCodexConfig(data: CodexConfigPatchInput): Promise<PatchCodexConfigResponse> {
const res = await fetch(withApiBase('/codex/config/patch'), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.status === 409) throw new ApiConflictError('Codex config changed externally');
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || 'Failed to patch Codex config');
}
return res.json();
}
export function useCodex() { export function useCodex() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -105,6 +127,14 @@ export function useCodex() {
}, },
}); });
const patchConfigMutation = useMutation({
mutationFn: patchCodexConfig,
onSuccess: (result) => {
queryClient.setQueryData<CodexRawConfig>(['codex-raw-config'], result);
queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] });
},
});
return useMemo( return useMemo(
() => ({ () => ({
diagnostics: diagnosticsQuery.data, diagnostics: diagnosticsQuery.data,
@@ -120,6 +150,9 @@ export function useCodex() {
saveRawConfig: saveRawConfigMutation.mutate, saveRawConfig: saveRawConfigMutation.mutate,
saveRawConfigAsync: saveRawConfigMutation.mutateAsync, saveRawConfigAsync: saveRawConfigMutation.mutateAsync,
isSavingRawConfig: saveRawConfigMutation.isPending, isSavingRawConfig: saveRawConfigMutation.isPending,
patchConfig: patchConfigMutation.mutate,
patchConfigAsync: patchConfigMutation.mutateAsync,
isPatchingConfig: patchConfigMutation.isPending,
}), }),
[ [
diagnosticsQuery.data, diagnosticsQuery.data,
@@ -133,6 +166,9 @@ export function useCodex() {
saveRawConfigMutation.mutate, saveRawConfigMutation.mutate,
saveRawConfigMutation.mutateAsync, saveRawConfigMutation.mutateAsync,
saveRawConfigMutation.isPending, saveRawConfigMutation.isPending,
patchConfigMutation.mutate,
patchConfigMutation.mutateAsync,
patchConfigMutation.isPending,
] ]
); );
} }
+225
View File
@@ -0,0 +1,225 @@
export interface CodexTopLevelSettingsView {
model: string | null;
modelReasoningEffort: string | null;
modelProvider: string | null;
approvalPolicy: string | null;
sandboxMode: string | null;
webSearch: string | null;
toolOutputTokenLimit: number | null;
personality: string | null;
}
export interface CodexProjectTrustEntry {
path: string;
trustLevel: string;
}
export interface CodexProfileEntry {
name: string;
values: CodexTopLevelSettingsView;
}
export interface CodexModelProviderEntry {
name: string;
displayName: string | null;
baseUrl: string | null;
envKey: string | null;
wireApi: string | null;
requiresOpenaiAuth: boolean;
supportsWebsockets: boolean;
}
export interface CodexMcpServerEntry {
name: string;
transport: 'stdio' | 'streamable-http';
command: string | null;
args: string[];
url: string | null;
enabled: boolean;
required: boolean;
startupTimeoutSec: number | null;
toolTimeoutSec: number | null;
enabledTools: string[];
disabledTools: string[];
}
export interface CodexFeatureCatalogEntry {
name: string;
label: string;
description: string;
}
export const KNOWN_CODEX_FEATURES: CodexFeatureCatalogEntry[] = [
{
name: 'multi_agent',
label: 'Multi-agent',
description: 'Enable subagent collaboration tools.',
},
{
name: 'unified_exec',
label: 'Unified exec',
description: 'Use the PTY-backed unified exec tool.',
},
{
name: 'shell_snapshot',
label: 'Shell snapshot',
description: 'Reuse shell environment snapshots.',
},
{
name: 'apply_patch_freeform',
label: 'Apply patch',
description: 'Enable freeform apply_patch edits.',
},
{ name: 'js_repl', label: 'JS REPL', description: 'Enable the Node-backed JavaScript REPL.' },
{
name: 'runtime_metrics',
label: 'Runtime metrics',
description: 'Collect Codex runtime metrics.',
},
{
name: 'prevent_idle_sleep',
label: 'Prevent idle sleep',
description: 'Keep the machine awake while active.',
},
{ name: 'fast_mode', label: 'Fast mode', description: 'Allow the fast service tier path.' },
{ name: 'apps', label: 'Apps', description: 'Enable ChatGPT Apps and connectors support.' },
{
name: 'smart_approvals',
label: 'Smart approvals',
description: 'Route eligible approvals through the guardian flow.',
},
];
function asObject(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
: [];
}
export function readCodexTopLevelSettings(
config: Record<string, unknown> | null
): CodexTopLevelSettingsView {
return {
model: asString(config?.model),
modelReasoningEffort: asString(config?.model_reasoning_effort),
modelProvider: asString(config?.model_provider),
approvalPolicy: asString(config?.approval_policy),
sandboxMode: asString(config?.sandbox_mode),
webSearch: asString(config?.web_search),
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
personality: asString(config?.personality),
};
}
export function readCodexProjectTrust(
config: Record<string, unknown> | null
): CodexProjectTrustEntry[] {
const projects = asObject(config?.projects);
if (!projects) return [];
return Object.entries(projects)
.map(([projectPath, value]) => {
const trustLevel = asString(asObject(value)?.trust_level);
return trustLevel ? { path: projectPath, trustLevel } : null;
})
.filter((entry): entry is CodexProjectTrustEntry => entry !== null)
.sort((left, right) => left.path.localeCompare(right.path));
}
export function readCodexProfiles(config: Record<string, unknown> | null): CodexProfileEntry[] {
const profiles = asObject(config?.profiles);
if (!profiles) return [];
return Object.entries(profiles)
.map(([name, value]) => ({ name, values: readCodexTopLevelSettings(asObject(value)) }))
.sort((left, right) => left.name.localeCompare(right.name));
}
export function readCodexModelProviders(
config: Record<string, unknown> | null
): CodexModelProviderEntry[] {
const providers = asObject(config?.model_providers);
if (!providers) return [];
return Object.entries(providers)
.map(([name, value]) => {
const provider = asObject(value);
if (!provider) return null;
return {
name,
displayName: asString(provider.name),
baseUrl: asString(provider.base_url),
envKey: asString(provider.env_key),
wireApi: asString(provider.wire_api),
requiresOpenaiAuth: provider.requires_openai_auth === true,
supportsWebsockets: provider.supports_websockets === true,
};
})
.filter((entry): entry is CodexModelProviderEntry => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
}
export function readCodexMcpServers(config: Record<string, unknown> | null): CodexMcpServerEntry[] {
const servers = asObject(config?.mcp_servers);
if (!servers) return [];
return Object.entries(servers)
.map(([name, value]) => {
const server = asObject(value);
if (!server) return null;
const transport = asString(server.command) ? 'stdio' : 'streamable-http';
return {
name,
transport,
command: asString(server.command),
args: asStringArray(server.args),
url: asString(server.url),
enabled: server.enabled !== false,
required: server.required === true,
startupTimeoutSec: asNumber(server.startup_timeout_sec),
toolTimeoutSec: asNumber(server.tool_timeout_sec),
enabledTools: asStringArray(server.enabled_tools),
disabledTools: asStringArray(server.disabled_tools),
};
})
.filter((entry): entry is CodexMcpServerEntry => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
}
export function readCodexFeatureState(
config: Record<string, unknown> | null
): Record<string, boolean | null> {
const features = asObject(config?.features);
const state: Record<string, boolean | null> = {};
for (const feature of KNOWN_CODEX_FEATURES) {
const value = features?.[feature.name];
state[feature.name] = typeof value === 'boolean' ? value : null;
}
if (features) {
for (const [name, value] of Object.entries(features)) {
if (!(name in state)) {
state[name] = typeof value === 'boolean' ? value : null;
}
}
}
return state;
}
+220 -54
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useState } from 'react'; import { type ReactNode, useMemo, useState } from 'react';
import { parse as parseToml } from 'smol-toml'; import { parse as parseToml } from 'smol-toml';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
@@ -6,7 +6,6 @@ import {
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
ExternalLink, ExternalLink,
FileWarning,
Folder, Folder,
GripVertical, GripVertical,
Info, Info,
@@ -19,7 +18,13 @@ import {
import { useCodex } from '@/hooks/use-codex'; import { useCodex } from '@/hooks/use-codex';
import { isApiConflictError } from '@/lib/api-client'; import { isApiConflictError } from '@/lib/api-client';
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
import { UsageCommand } from '@/components/cliproxy/provider-editor/usage-command'; import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card';
import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card';
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
import { QuickCommands } from '@/components/shared';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -33,6 +38,15 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
KNOWN_CODEX_FEATURES,
readCodexFeatureState,
readCodexMcpServers,
readCodexModelProviders,
readCodexProfiles,
readCodexProjectTrust,
readCodexTopLevelSettings,
} from '@/lib/codex-config';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const DEFAULT_CODEX_DOC_LINKS = [ const DEFAULT_CODEX_DOC_LINKS = [
@@ -157,9 +171,12 @@ export function CodexPage() {
refetchDiagnostics, refetchDiagnostics,
rawConfig, rawConfig,
rawConfigLoading, rawConfigLoading,
rawConfigError,
refetchRawConfig, refetchRawConfig,
saveRawConfigAsync, saveRawConfigAsync,
isSavingRawConfig, isSavingRawConfig,
patchConfigAsync,
isPatchingConfig,
} = useCodex(); } = useCodex();
const [rawDraftText, setRawDraftText] = useState<string | null>(null); const [rawDraftText, setRawDraftText] = useState<string | null>(null);
@@ -170,6 +187,34 @@ export function CodexPage() {
const rawEditorValidation = rawEditorParsed.valid const rawEditorValidation = rawEditorParsed.valid
? { valid: true as const } ? { valid: true as const }
: { valid: false as const, error: rawEditorParsed.error }; : { valid: false as const, error: rawEditorParsed.error };
const controlsConfig = rawConfig?.config ?? null;
const structuredControlsDisabled =
rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null;
const controlsDisabledReason = rawConfigError
? 'Structured controls unavailable: failed to load the current config.toml.'
: rawConfigDirty
? rawEditorValidation.valid
? 'Save or discard raw TOML edits before using structured controls.'
: 'Fix or discard raw TOML edits before using structured controls.'
: rawConfig?.parseError
? `Structured controls disabled: ${rawConfig.parseError}`
: null;
const topLevelSettings = useMemo(
() => readCodexTopLevelSettings(controlsConfig),
[controlsConfig]
);
const projectTrustEntries = useMemo(
() => readCodexProjectTrust(controlsConfig),
[controlsConfig]
);
const profileEntries = useMemo(() => readCodexProfiles(controlsConfig), [controlsConfig]);
const modelProviderEntries = useMemo(
() => readCodexModelProviders(controlsConfig),
[controlsConfig]
);
const mcpServerEntries = useMemo(() => readCodexMcpServers(controlsConfig), [controlsConfig]);
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
const setRawEditorDraftText = (nextText: string) => { const setRawEditorDraftText = (nextText: string) => {
if (nextText === rawBaseText) { if (nextText === rawBaseText) {
@@ -206,6 +251,26 @@ export function CodexPage() {
} }
}; };
const runConfigPatch = async (
patch: Parameters<typeof patchConfigAsync>[0],
successMessage: string
) => {
try {
await patchConfigAsync({
...patch,
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
});
setRawDraftText(null);
toast.success(successMessage);
} catch (error) {
if (isApiConflictError(error)) {
toast.error('config.toml changed externally. Refresh and retry.');
} else {
toast.error((error as Error).message || 'Failed to update Codex config.');
}
}
};
const renderOverview = () => { const renderOverview = () => {
if (diagnosticsLoading) { if (diagnosticsLoading) {
return ( return (
@@ -242,7 +307,7 @@ export function CodexPage() {
<div className="shrink-0 px-4 pt-4"> <div className="shrink-0 px-4 pt-4">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="routing">Runtime &amp; Routing</TabsTrigger> <TabsTrigger value="controls">Control Center</TabsTrigger>
<TabsTrigger value="docs">Docs</TabsTrigger> <TabsTrigger value="docs">Docs</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -424,30 +489,31 @@ export function CodexPage() {
</CardContent> </CardContent>
</Card> </Card>
{diagnostics.warnings.length > 0 && ( <QuickCommands
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20"> snippets={[
<CardHeader className="pb-2"> {
<CardTitle className="flex items-center gap-2 text-base"> label: 'Native Codex',
<AlertTriangle className="h-4 w-4 text-amber-600" /> command: 'ccs-codex',
Warnings description: 'Launch the native Codex runtime alias.',
</CardTitle> },
</CardHeader> {
<CardContent className="space-y-1.5"> label: 'Short alias',
{diagnostics.warnings.map((warning) => ( command: 'ccsx',
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300"> description: 'Launch the short Codex runtime alias.',
- {warning} },
</p> {
))} label: 'Target override',
</CardContent> command: 'ccs codex --target codex "your prompt"',
</Card> description: 'Run a CCS profile on the native Codex target.',
)} },
</div> {
</ScrollArea> label: 'Workspace trust',
</TabsContent> command: `codex --profile ${diagnostics.config.activeProfile || 'default'}`,
description: 'Inspect the active profile directly in native Codex.',
},
]}
/>
<TabsContent value="routing" className={tabContentClassName}>
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@@ -507,46 +573,146 @@ export function CodexPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card> {diagnostics.warnings.length > 0 && (
<CardHeader className="pb-2"> <Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardTitle className="text-base">Quick usage</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="flex items-center gap-2 text-base">
<CardContent className="space-y-3"> <AlertTriangle className="h-4 w-4 text-amber-600" />
<UsageCommand label="Codex alias (explicit)" command="ccs-codex" /> Warnings
<UsageCommand label="Codex alias (short)" command="ccsx" /> </CardTitle>
<UsageCommand </CardHeader>
label="Run a profile on native Codex" <CardContent className="space-y-1.5">
command='ccs codex --target codex "your prompt"' {diagnostics.warnings.map((warning) => (
/> <p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
<UsageCommand - {warning}
label="Routed profile example" </p>
command='ccs mycodex --target codex "your prompt"' ))}
/> </CardContent>
</CardContent> </Card>
</Card> )}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="controls" className={tabContentClassName}>
<ScrollArea className="h-full">
<div className="space-y-4 pr-1">
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<FileWarning className="h-4 w-4" /> <Route className="h-4 w-4" />
What this editor affects Structured controls boundary
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground"> <CardContent className="space-y-2 text-sm text-muted-foreground">
<p> <p>
Edits here affect native Codex sessions first because this is the user-layer{' '} Guided controls write only the user-layer <code>config.toml</code>. They do
<code>config.toml</code>. not model the full effective Codex runtime once trusted repo layers and CCS
transient <code>-c</code> overrides are involved.
</p> </p>
<p> <p>
CCS-routed Codex launches may override provider-related keys transiently and Structured saves normalize TOML formatting and strip comments. Use the raw
inject <code>CCS_CODEX_API_KEY</code>. editor on the right when exact layout matters.
</p>
<p>
That means the file is not a complete source of truth for every routed Codex
launch you start from CCS.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<CodexTopLevelControlsCard
values={topLevelSettings}
providerNames={modelProviderEntries.map((entry) => entry.name)}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
saving={isPatchingConfig}
onSave={(values) =>
runConfigPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
}
/>
<CodexProjectTrustCard
workspacePath={diagnostics.workspacePath}
entries={projectTrustEntries}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
saving={isPatchingConfig}
onSave={(projectPath, trustLevel) =>
runConfigPatch(
{ kind: 'project-trust', path: projectPath, trustLevel },
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
)
}
/>
<CodexProfilesCard
activeProfile={diagnostics.config.activeProfile}
entries={profileEntries}
providerNames={modelProviderEntries.map((entry) => entry.name)}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
saving={isPatchingConfig}
onSave={(name, values, setAsActive) =>
runConfigPatch(
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
'Saved profile.'
)
}
onDelete={(name) =>
runConfigPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
}
onSetActive={(name) =>
runConfigPatch(
{ kind: 'profile', action: 'set-active', name },
'Set active profile.'
)
}
/>
<CodexModelProvidersCard
entries={modelProviderEntries}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
saving={isPatchingConfig}
onSave={(name, values) =>
runConfigPatch(
{ kind: 'model-provider', action: 'upsert', name, values },
'Saved model provider.'
)
}
onDelete={(name) =>
runConfigPatch(
{ kind: 'model-provider', action: 'delete', name },
'Deleted model provider.'
)
}
/>
<CodexMcpServersCard
entries={mcpServerEntries}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
saving={isPatchingConfig}
onSave={(name, values) =>
runConfigPatch(
{ kind: 'mcp-server', action: 'upsert', name, values },
'Saved MCP server.'
)
}
onDelete={(name) =>
runConfigPatch(
{ kind: 'mcp-server', action: 'delete', name },
'Deleted MCP server.'
)
}
/>
<CodexFeaturesCard
catalog={KNOWN_CODEX_FEATURES}
state={featureState}
disabled={structuredControlsDisabled}
disabledReason={controlsDisabledReason}
onToggle={(feature, enabled) =>
runConfigPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
}
/>
</div> </div>
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
+142
View File
@@ -0,0 +1,142 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { AllProviders } from '../../setup/test-utils';
import { useCodex } from '@/hooks/use-codex';
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const diagnosticsResponse = {
binary: {
installed: true,
path: '/tmp/codex',
installDir: '/tmp',
source: 'PATH',
version: 'codex-cli 0.118.0-alpha.3',
overridePath: null,
supportsConfigOverrides: true,
},
file: {
label: 'Codex user config',
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
isSymlink: false,
isRegularFile: true,
sizeBytes: 64,
mtimeMs: 100,
parseError: null,
readError: null,
},
workspacePath: '/tmp/workspace',
config: {
model: 'gpt-5.3-codex',
modelReasoningEffort: null,
modelProvider: null,
activeProfile: null,
approvalPolicy: null,
sandboxMode: null,
webSearch: null,
toolOutputTokenLimit: null,
personality: null,
topLevelKeys: ['model'],
profileCount: 0,
profileNames: [],
modelProviderCount: 0,
modelProviders: [],
featureCount: 0,
enabledFeatures: [],
disabledFeatures: [],
trustedProjectCount: 0,
untrustedProjectCount: 0,
projectTrust: [],
mcpServerCount: 0,
mcpServers: [],
},
supportMatrix: [],
warnings: [],
docsReference: {
providerValues: [],
settingsHierarchy: [],
notes: [],
links: [],
providerDocs: [],
},
};
const initialRawConfigResponse = {
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 100,
rawText: 'model = "gpt-5.3-codex"\n',
config: { model: 'gpt-5.3-codex' },
parseError: null,
};
const patchedRawConfigResponse = {
success: true,
path: '$CODEX_HOME/config.toml',
resolvedPath: '/tmp/.codex/config.toml',
exists: true,
mtime: 200,
rawText: 'model = "gpt-5.4"\n',
config: { model: 'gpt-5.4' },
parseError: null,
};
const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>;
describe('useCodex', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('updates cached raw config immediately after a structured patch save', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/codex/diagnostics')) {
return Promise.resolve(createJsonResponse(diagnosticsResponse));
}
if (url.endsWith('/api/codex/config/raw') && !init?.method) {
return Promise.resolve(createJsonResponse(initialRawConfigResponse));
}
if (url.endsWith('/api/codex/config/patch')) {
return Promise.resolve(createJsonResponse(patchedRawConfigResponse));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useCodex(), { wrapper });
await waitFor(() => expect(result.current.rawConfig?.mtime).toBe(100));
await act(async () => {
await result.current.patchConfigAsync({
kind: 'top-level',
values: { model: 'gpt-5.4' },
expectedMtime: 100,
});
});
await waitFor(() => expect(result.current.rawConfig?.mtime).toBe(200));
expect(result.current.rawConfig?.rawText).toBe('model = "gpt-5.4"\n');
expect(result.current.rawConfig?.config?.model).toBe('gpt-5.4');
expect(
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/codex/config/raw'))
).toHaveLength(1);
});
});