mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
@@ -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<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;
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>, 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<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'] {
|
||||
if (asString(server.command)) return 'stdio';
|
||||
if (asString(server.url)) return 'streamable-http';
|
||||
@@ -294,13 +722,17 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
|
||||
supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo),
|
||||
},
|
||||
file: fileProbe.diagnostics,
|
||||
workspacePath: process.cwd(),
|
||||
config: {
|
||||
model: asString(config?.model),
|
||||
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||
modelProvider: asString(config?.model_provider),
|
||||
activeProfile,
|
||||
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),
|
||||
topLevelKeys,
|
||||
profileCount: profileNames.length,
|
||||
profileNames,
|
||||
@@ -357,3 +789,57 @@ export async function saveCodexRawConfig(
|
||||
|
||||
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 * 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, 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(
|
||||
filePath: string,
|
||||
label: string,
|
||||
|
||||
@@ -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<string, unknown> | 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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown> | 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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
@@ -69,6 +76,21 @@ async function saveCodexRawConfig(
|
||||
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() {
|
||||
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(
|
||||
() => ({
|
||||
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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<string | null>(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<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 = () => {
|
||||
if (diagnosticsLoading) {
|
||||
return (
|
||||
@@ -242,7 +307,7 @@ export function CodexPage() {
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="routing">Runtime & Routing</TabsTrigger>
|
||||
<TabsTrigger value="controls">Control Center</TabsTrigger>
|
||||
<TabsTrigger value="docs">Docs</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -424,30 +489,31 @@ export function CodexPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{diagnostics.warnings.length > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
Warnings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{diagnostics.warnings.map((warning) => (
|
||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||
- {warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<QuickCommands
|
||||
snippets={[
|
||||
{
|
||||
label: 'Native Codex',
|
||||
command: 'ccs-codex',
|
||||
description: 'Launch the native Codex runtime alias.',
|
||||
},
|
||||
{
|
||||
label: 'Short alias',
|
||||
command: 'ccsx',
|
||||
description: 'Launch the short Codex runtime alias.',
|
||||
},
|
||||
{
|
||||
label: 'Target override',
|
||||
command: 'ccs codex --target codex "your prompt"',
|
||||
description: 'Run a CCS profile on the native Codex target.',
|
||||
},
|
||||
{
|
||||
label: 'Workspace trust',
|
||||
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>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -507,46 +573,146 @@ export function CodexPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Quick usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<UsageCommand label="Codex alias (explicit)" command="ccs-codex" />
|
||||
<UsageCommand label="Codex alias (short)" command="ccsx" />
|
||||
<UsageCommand
|
||||
label="Run a profile on native Codex"
|
||||
command='ccs codex --target codex "your prompt"'
|
||||
/>
|
||||
<UsageCommand
|
||||
label="Routed profile example"
|
||||
command='ccs mycodex --target codex "your prompt"'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{diagnostics.warnings.length > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
Warnings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
{diagnostics.warnings.map((warning) => (
|
||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||
- {warning}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="controls" className={tabContentClassName}>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 pr-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileWarning className="h-4 w-4" />
|
||||
What this editor affects
|
||||
<Route className="h-4 w-4" />
|
||||
Structured controls boundary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Edits here affect native Codex sessions first because this is the user-layer{' '}
|
||||
<code>config.toml</code>.
|
||||
Guided controls write only the user-layer <code>config.toml</code>. They do
|
||||
not model the full effective Codex runtime once trusted repo layers and CCS
|
||||
transient <code>-c</code> overrides are involved.
|
||||
</p>
|
||||
<p>
|
||||
CCS-routed Codex launches may override provider-related keys transiently and
|
||||
inject <code>CCS_CODEX_API_KEY</code>.
|
||||
</p>
|
||||
<p>
|
||||
That means the file is not a complete source of truth for every routed Codex
|
||||
launch you start from CCS.
|
||||
Structured saves normalize TOML formatting and strip comments. Use the raw
|
||||
editor on the right when exact layout matters.
|
||||
</p>
|
||||
</CardContent>
|
||||
</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>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user