diff --git a/src/web-server/routes/codex-routes.ts b/src/web-server/routes/codex-routes.ts index 83cc5f7e..e7abaf84 100644 --- a/src/web-server/routes/codex-routes.ts +++ b/src/web-server/routes/codex-routes.ts @@ -5,6 +5,7 @@ import { CodexRawConfigValidationError, getCodexDashboardDiagnostics, getCodexRawConfig, + patchCodexConfig, saveCodexRawConfig, } from '../services/codex-dashboard-service'; @@ -56,4 +57,33 @@ router.put('/config/raw', async (req: Request, res: Response): Promise => } }); +router.patch('/config/patch', async (req: Request, res: Response): Promise => { + 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; diff --git a/src/web-server/services/codex-dashboard-service.ts b/src/web-server/services/codex-dashboard-service.ts index 244f4bae..ca98b630 100644 --- a/src/web-server/services/codex-dashboard-service.ts +++ b/src/web-server/services/codex-dashboard-service.ts @@ -6,6 +6,8 @@ import { getCodexBinaryInfo, } from '../../targets/codex-detector'; import type { + CodexConfigPatchInput, + CodexConfigPatchResult, CodexDashboardDiagnostics, CodexFeatureFlagDiagnostics, CodexMcpServerDiagnostics, @@ -18,6 +20,7 @@ import { TomlFileConflictError, TomlFileValidationError, probeTomlObjectFile, + stringifyTomlObject, writeTomlFileAtomic, } from './compatible-cli-toml-file-service'; import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; @@ -44,6 +47,32 @@ export { 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 { 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; } -function hasOwn(obj: Record, key: string): boolean { +function hasOwn(obj: object, key: string): boolean { 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, key: string): Record { + const existing = asObject(target[key]); + if (existing) return existing; + + const next: Record = {}; + target[key] = next; + return next; +} + +function deleteIfEmpty(target: Record, key: string) { + const value = asObject(target[key]); + if (value && Object.keys(value).length === 0) { + delete target[key]; + } +} + +function setStringField(target: Record, key: string, value: unknown) { + if (!isNonEmptyString(value)) { + delete target[key]; + return; + } + target[key] = value.trim(); +} + +function setEnumStringField( + target: Record, + key: string, + value: unknown, + allowedValues: Set, + 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, key: string, value: unknown) { + if (typeof value !== 'boolean') { + delete target[key]; + return; + } + target[key] = value; +} + +function setNumberField( + target: Record, + 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 | null; +}): Record { + 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, + values: Extract['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, + input: Extract +) { + 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, + input: Extract +) { + 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, + input: Extract +) { + 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, + input: Extract +) { + 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, + input: Extract +) { + 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): CodexMcpServerDiagnostics['transport'] { if (asString(server.command)) return 'stdio'; if (asString(server.url)) return 'streamable-http'; @@ -294,13 +722,17 @@ export async function getCodexDashboardDiagnostics(): Promise { + 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, + }; +} diff --git a/src/web-server/services/compatible-cli-toml-file-service.ts b/src/web-server/services/compatible-cli-toml-file-service.ts index b7780711..312a04c3 100644 --- a/src/web-server/services/compatible-cli-toml-file-service.ts +++ b/src/web-server/services/compatible-cli-toml-file-service.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import { parse } from 'smol-toml'; +import { parse, stringify } from 'smol-toml'; export interface TomlFileDiagnostics { label: string; @@ -92,6 +92,15 @@ export function parseTomlObjectText( return parsed; } +export function stringifyTomlObject(config: Record): 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( filePath: string, label: string, diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts index e3c749bb..dd29fcc7 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -146,11 +146,14 @@ export interface CodexSupportMatrixEntry { export interface CodexUserConfigDiagnostics { model: string | null; + modelReasoningEffort: string | null; modelProvider: string | null; activeProfile: string | null; approvalPolicy: string | null; sandboxMode: string | null; webSearch: string | null; + toolOutputTokenLimit: number | null; + personality: string | null; topLevelKeys: string[]; profileCount: number; profileNames: string[]; @@ -169,6 +172,7 @@ export interface CodexUserConfigDiagnostics { export interface CodexDashboardDiagnostics { binary: CodexBinaryDiagnostics; file: CodexConfigFileDiagnostics; + workspacePath: string; config: CodexUserConfigDiagnostics; supportMatrix: CodexSupportMatrixEntry[]; warnings: string[]; @@ -184,3 +188,83 @@ export interface CodexRawConfigResponse { config: Record | 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; +} diff --git a/tests/unit/web-server/codex-dashboard-service.test.ts b/tests/unit/web-server/codex-dashboard-service.test.ts index bfbdf179..77e837c8 100644 --- a/tests/unit/web-server/codex-dashboard-service.test.ts +++ b/tests/unit/web-server/codex-dashboard-service.test.ts @@ -7,6 +7,7 @@ import { CodexRawConfigValidationError, getCodexDashboardDiagnostics, getCodexRawConfig, + patchCodexConfig, resolveCodexConfigPaths, saveCodexRawConfig, summarizeCodexFeatureFlags, @@ -262,4 +263,172 @@ bearer_token = "secret" }) ).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); + }); }); diff --git a/tests/unit/web-server/codex-routes.test.ts b/tests/unit/web-server/codex-routes.test.ts new file mode 100644 index 00000000..78083ef9 --- /dev/null +++ b/tests/unit/web-server/codex-routes.test.ts @@ -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((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((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 | 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'); + }); +}); diff --git a/ui/src/components/compatible-cli/codex-config-card-shell.tsx b/ui/src/components/compatible-cli/codex-config-card-shell.tsx new file mode 100644 index 00000000..f3375e22 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-config-card-shell.tsx @@ -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 ( + + + + {icon} + {title} + {badge ? ( + + {badge} + + ) : null} + + {description ?

{description}

: null} +
+ + {disabledReason ?

{disabledReason}

: null} + {children} +
+
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-features-card.tsx b/ui/src/components/compatible-cli/codex-features-card.tsx new file mode 100644 index 00000000..1d7a95c1 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-features-card.tsx @@ -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; + disabled?: boolean; + disabledReason?: string | null; + onToggle: (feature: string, enabled: boolean | null) => Promise | 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 ( + } + description="Toggle the supported Codex feature flags CCS can safely manage." + disabledReason={disabledReason} + > +
+ {catalog.map((feature) => { + const current = state[feature.name] ?? null; + return ( +
+
+
+

{feature.label}

+ + {feature.name} + +
+

{feature.description}

+
+
+ {current !== null ? ( + + ) : null} + onToggle(feature.name, next)} + disabled={disabled} + /> +
+
+ ); + })} +
+ + {configOnlyFeatures.length > 0 ? ( +
+
+

+ Existing config-only flags +

+

+ These feature keys already exist in your `config.toml`, so CCS can surface them + without claiming full catalog coverage. +

+
+ {configOnlyFeatures.map(([name, current]) => ( +
+
+
+

{name}

+ + existing + +
+

+ {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."} +

+
+ {current === null ? ( + Raw only + ) : ( +
+ + onToggle(name, next)} + disabled={disabled} + /> +
+ )} +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx new file mode 100644 index 00000000..79f1aa39 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx @@ -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; + onDelete: (name: string) => Promise | 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; + onDelete: () => Promise | void; +} + +function McpServerEditor({ + initialDraft, + isNew, + disabled, + saving, + canDelete, + onSave, + onDelete, +}: McpServerEditorProps) { + const [draft, setDraft] = useState(initialDraft); + + return ( + <> +
+ setDraft((current) => ({ ...current, name: event.target.value }))} + placeholder="playwright" + disabled={disabled || !isNew} + /> + + {draft.transport === 'stdio' ? ( + <> + + setDraft((current) => ({ ...current, command: event.target.value || null })) + } + placeholder="npx" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, args: fromCsv(event.target.value) })) + } + placeholder="@playwright/mcp@latest, --flag" + disabled={disabled} + /> + + ) : ( + + setDraft((current) => ({ ...current, url: event.target.value || null })) + } + placeholder="https://example.test/mcp" + disabled={disabled} + /> + )} + + setDraft((current) => ({ + ...current, + startupTimeoutSec: event.target.value ? Number(event.target.value) : null, + })) + } + placeholder="Startup timeout (sec)" + disabled={disabled} + /> + + setDraft((current) => ({ + ...current, + toolTimeoutSec: event.target.value ? Number(event.target.value) : null, + })) + } + placeholder="Tool timeout (sec)" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, enabledTools: fromCsv(event.target.value) })) + } + placeholder="enabled_tools" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, disabledTools: fromCsv(event.target.value) })) + } + placeholder="disabled_tools" + disabled={disabled} + /> +
+ +
+ + +
+ +
+ + +
+ + ); +} + +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 ( + } + description="Manage the safe MCP transport fields. Keep auth headers and bearer tokens in raw TOML." + disabledReason={disabledReason} + > + + { + if (!selectedEntry) return; + await onDelete(selectedEntry.name); + setSelectedName('new'); + }} + onSave={async (name, values) => { + await onSave(name, values); + setSelectedName(name); + }} + /> + + ); +} diff --git a/ui/src/components/compatible-cli/codex-model-providers-card.tsx b/ui/src/components/compatible-cli/codex-model-providers-card.tsx new file mode 100644 index 00000000..7d3b9814 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-model-providers-card.tsx @@ -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; + onDelete: (name: string) => Promise | 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; + onDelete: () => Promise | void; +} + +function ModelProviderEditor({ + initialDraft, + isNew, + disabled, + saving, + canDelete, + onSave, + onDelete, +}: ModelProviderEditorProps) { + const [draft, setDraft] = useState(initialDraft); + + return ( + <> +
+ setDraft((current) => ({ ...current, name: event.target.value }))} + placeholder="Provider id" + disabled={disabled || !isNew} + /> + + setDraft((current) => ({ ...current, displayName: event.target.value || null })) + } + placeholder="Display name" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, baseUrl: event.target.value || null })) + } + placeholder="http://127.0.0.1:8317/api/provider/codex" + disabled={disabled} + /> + + setDraft((current) => ({ ...current, envKey: event.target.value || null })) + } + placeholder="CLIPROXY_API_KEY" + disabled={disabled} + /> +
+ +
+ + + +
+ +
+ + +
+ + ); +} + +export function CodexModelProvidersCard({ + entries, + disabled = false, + disabledReason, + saving = false, + onSave, + onDelete, +}: CodexModelProvidersCardProps) { + const [selectedName, setSelectedName] = useState('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 ( + } + description="Edit the common provider fields CCS can support safely. Keep secret migration and inline bearer tokens in raw TOML." + disabledReason={disabledReason} + > + + { + if (!selectedEntry) return; + await onDelete(selectedEntry.name); + setSelectedName('new'); + }} + onSave={async (name, values) => { + await onSave(name, values); + setSelectedName(name); + }} + /> + + ); +} diff --git a/ui/src/components/compatible-cli/codex-profiles-card.tsx b/ui/src/components/compatible-cli/codex-profiles-card.tsx new file mode 100644 index 00000000..f2e002a7 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-profiles-card.tsx @@ -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; + onDelete: (name: string) => Promise | void; + onSetActive: (name: string) => Promise | 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; + onDelete: () => Promise | void; + onSetActive: () => Promise | 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(initialModel); + const [providerDraft, setProviderDraft] = useState(initialProvider); + const [effortDraft, setEffortDraft] = useState(initialEffort); + + return ( + <> +
+ setNameDraft(event.target.value)} + placeholder="deep-review" + disabled={disabled || selectedEntryName !== null} + /> + setModelDraft(event.target.value || null)} + placeholder="gpt-5.4" + disabled={disabled} + /> + + +
+ +
+
+ + +
+
+ + +
+
+ + ); +} + +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 ( + } + description="Create reusable Codex overlays and set the active default profile." + disabledReason={disabledReason} + > + + { + 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); + }} + /> + + ); +} diff --git a/ui/src/components/compatible-cli/codex-project-trust-card.tsx b/ui/src/components/compatible-cli/codex-project-trust-card.tsx new file mode 100644 index 00000000..f9779f60 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-project-trust-card.tsx @@ -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; +} + +interface ProjectTrustComposerProps { + workspacePath: string; + disabled: boolean; + saving: boolean; + onSave: (path: string, trustLevel: string | null) => Promise | void; +} + +function ProjectTrustComposer({ + workspacePath, + disabled, + saving, + onSave, +}: ProjectTrustComposerProps) { + const [pathDraft, setPathDraft] = useState(workspacePath); + const [trustLevel, setTrustLevel] = useState('trusted'); + + return ( +
+ setPathDraft(event.target.value)} + placeholder="~/repo or /absolute/path" + disabled={disabled} + /> + + +
+ ); +} + +export function CodexProjectTrustCard({ + workspacePath, + entries, + disabled = false, + disabledReason, + saving = false, + onSave, +}: CodexProjectTrustCardProps) { + return ( + } + description="Trust current workspaces or remove stale trust entries without opening raw TOML." + disabledReason={disabledReason} + > +

+ Paths must be absolute or start with ~/. Relative paths are rejected so CCS + does not trust the wrong folder. +

+ + + + +
+ {entries.length === 0 ? ( +

No explicit project trust entries saved.

+ ) : ( + entries.map((entry) => ( +
+
+

{entry.path}

+

trust_level = {entry.trustLevel}

+
+
+ + +
+
+ )) + )} +
+
+ ); +} diff --git a/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx new file mode 100644 index 00000000..787724f6 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx @@ -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; +} + +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; +} + +function TopLevelControlsForm({ + initialValues, + providerNames, + disabled, + saving, + onSave, +}: TopLevelControlsFormProps) { + const [draft, setDraft] = useState(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 ( + <> +
+
+

Model

+ + setDraft((current) => ({ ...current, model: event.target.value || null })) + } + placeholder="gpt-5.4" + disabled={disabled} + /> +
+ +
+

Reasoning effort

+ +
+ +
+

Default provider

+ +
+ +
+

Approval policy

+ +
+ +
+

Sandbox mode

+ +
+ +
+

Web search

+ +
+ +
+

Tool output token limit

+ + setDraft((current) => ({ + ...current, + toolOutputTokenLimit: event.target.value ? Number(event.target.value) : null, + })) + } + placeholder="25000" + disabled={disabled} + /> +
+ +
+

Personality

+ +
+
+ +
+ +
+ + ); +} + +export function CodexTopLevelControlsCard({ + values, + providerNames, + disabled = false, + disabledReason, + saving = false, + onSave, +}: CodexTopLevelControlsCardProps) { + return ( + } + description="Structured controls for the stable top-level Codex settings users touch most often." + disabledReason={disabledReason} + > + + + ); +} diff --git a/ui/src/hooks/use-codex-types.ts b/ui/src/hooks/use-codex-types.ts index 64ddba94..af554937 100644 --- a/ui/src/hooks/use-codex-types.ts +++ b/ui/src/hooks/use-codex-types.ts @@ -88,11 +88,14 @@ export interface CodexSupportMatrixEntry { export interface CodexUserConfigDiagnostics { model: string | null; + modelReasoningEffort: string | null; modelProvider: string | null; activeProfile: string | null; approvalPolicy: string | null; sandboxMode: string | null; webSearch: string | null; + toolOutputTokenLimit: number | null; + personality: string | null; topLevelKeys: string[]; profileCount: number; profileNames: string[]; @@ -111,6 +114,7 @@ export interface CodexUserConfigDiagnostics { export interface CodexDashboardDiagnostics { binary: CodexBinaryDiagnostics; file: CodexConfigFileDiagnostics; + workspacePath: string; config: CodexUserConfigDiagnostics; supportMatrix: CodexSupportMatrixEntry[]; warnings: string[]; @@ -126,3 +130,83 @@ export interface CodexRawConfigResponse { config: Record | 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; +} diff --git a/ui/src/hooks/use-codex.ts b/ui/src/hooks/use-codex.ts index ae37799d..4123f272 100644 --- a/ui/src/hooks/use-codex.ts +++ b/ui/src/hooks/use-codex.ts @@ -2,7 +2,12 @@ import { useMemo } from 'react'; import { parse as parseToml } from 'smol-toml'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApiConflictError, withApiBase } from '@/lib/api-client'; -import type { CodexDashboardDiagnostics, CodexRawConfigResponse } from './use-codex-types'; +import type { + CodexConfigPatchInput, + CodexConfigPatchResult, + CodexDashboardDiagnostics, + CodexRawConfigResponse, +} from './use-codex-types'; type CodexRawConfig = CodexRawConfigResponse; @@ -16,6 +21,8 @@ interface SaveCodexRawConfigResponse { mtime: number; } +type PatchCodexConfigResponse = CodexConfigPatchResult; + function parseCodexRawConfigText(rawText: string): { config: Record | null; parseError: string | null; @@ -69,6 +76,21 @@ async function saveCodexRawConfig( return res.json(); } +async function patchCodexConfig(data: CodexConfigPatchInput): Promise { + 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() { const queryClient = useQueryClient(); @@ -105,6 +127,14 @@ export function useCodex() { }, }); + const patchConfigMutation = useMutation({ + mutationFn: patchCodexConfig, + onSuccess: (result) => { + queryClient.setQueryData(['codex-raw-config'], result); + queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] }); + }, + }); + return useMemo( () => ({ diagnostics: diagnosticsQuery.data, @@ -120,6 +150,9 @@ export function useCodex() { saveRawConfig: saveRawConfigMutation.mutate, saveRawConfigAsync: saveRawConfigMutation.mutateAsync, isSavingRawConfig: saveRawConfigMutation.isPending, + patchConfig: patchConfigMutation.mutate, + patchConfigAsync: patchConfigMutation.mutateAsync, + isPatchingConfig: patchConfigMutation.isPending, }), [ diagnosticsQuery.data, @@ -133,6 +166,9 @@ export function useCodex() { saveRawConfigMutation.mutate, saveRawConfigMutation.mutateAsync, saveRawConfigMutation.isPending, + patchConfigMutation.mutate, + patchConfigMutation.mutateAsync, + patchConfigMutation.isPending, ] ); } diff --git a/ui/src/lib/codex-config.ts b/ui/src/lib/codex-config.ts new file mode 100644 index 00000000..a2ece0c4 --- /dev/null +++ b/ui/src/lib/codex-config.ts @@ -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 | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : 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 | 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 | 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 | 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 | 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 | 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 | null +): Record { + const features = asObject(config?.features); + const state: Record = {}; + + 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; +} diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx index ef5b8fe6..d7449554 100644 --- a/ui/src/pages/codex.tsx +++ b/ui/src/pages/codex.tsx @@ -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 { toast } from 'sonner'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; @@ -6,7 +6,6 @@ import { AlertTriangle, CheckCircle2, ExternalLink, - FileWarning, Folder, GripVertical, Info, @@ -19,7 +18,13 @@ import { import { useCodex } from '@/hooks/use-codex'; import { isApiConflictError } from '@/lib/api-client'; import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; -import { UsageCommand } from '@/components/cliproxy/provider-editor/usage-command'; +import { 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -33,6 +38,15 @@ import { TableRow, } from '@/components/ui/table'; 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'; const DEFAULT_CODEX_DOC_LINKS = [ @@ -157,9 +171,12 @@ export function CodexPage() { refetchDiagnostics, rawConfig, rawConfigLoading, + rawConfigError, refetchRawConfig, saveRawConfigAsync, isSavingRawConfig, + patchConfigAsync, + isPatchingConfig, } = useCodex(); const [rawDraftText, setRawDraftText] = useState(null); @@ -170,6 +187,34 @@ export function CodexPage() { const rawEditorValidation = rawEditorParsed.valid ? { valid: true as const } : { 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) => { if (nextText === rawBaseText) { @@ -206,6 +251,26 @@ export function CodexPage() { } }; + const runConfigPatch = async ( + patch: Parameters[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 = () => { if (diagnosticsLoading) { return ( @@ -242,7 +307,7 @@ export function CodexPage() {
Overview - Runtime & Routing + Control Center Docs
@@ -424,30 +489,31 @@ export function CodexPage() { - {diagnostics.warnings.length > 0 && ( - - - - - Warnings - - - - {diagnostics.warnings.map((warning) => ( -

- - {warning} -

- ))} -
-
- )} - - - + - - -
@@ -507,46 +573,146 @@ export function CodexPage() { - - - Quick usage - - - - - - - - + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+
+ + +
- - What this editor affects + + Structured controls boundary

- Edits here affect native Codex sessions first because this is the user-layer{' '} - config.toml. + Guided controls write only the user-layer config.toml. They do + not model the full effective Codex runtime once trusted repo layers and CCS + transient -c overrides are involved.

- CCS-routed Codex launches may override provider-related keys transiently and - inject CCS_CODEX_API_KEY. -

-

- That means the file is not a complete source of truth for every routed Codex - launch you start from CCS. + Structured saves normalize TOML formatting and strip comments. Use the raw + editor on the right when exact layout matters.

+ + entry.name)} + disabled={structuredControlsDisabled} + disabledReason={controlsDisabledReason} + saving={isPatchingConfig} + onSave={(values) => + runConfigPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.') + } + /> + + + runConfigPatch( + { kind: 'project-trust', path: projectPath, trustLevel }, + trustLevel ? 'Saved project trust entry.' : 'Removed project trust 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.' + ) + } + /> + + + runConfigPatch( + { kind: 'model-provider', action: 'upsert', name, values }, + 'Saved model provider.' + ) + } + onDelete={(name) => + runConfigPatch( + { kind: 'model-provider', action: 'delete', name }, + 'Deleted model provider.' + ) + } + /> + + + runConfigPatch( + { kind: 'mcp-server', action: 'upsert', name, values }, + 'Saved MCP server.' + ) + } + onDelete={(name) => + runConfigPatch( + { kind: 'mcp-server', action: 'delete', name }, + 'Deleted MCP server.' + ) + } + /> + + + runConfigPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.') + } + />
diff --git a/ui/tests/unit/hooks/use-codex.test.tsx b/ui/tests/unit/hooks/use-codex.test.tsx new file mode 100644 index 00000000..0d3bfb67 --- /dev/null +++ b/ui/tests/unit/hooks/use-codex.test.tsx @@ -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, 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 }) => {children}; + +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); + }); +});