mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +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,
|
CodexRawConfigValidationError,
|
||||||
getCodexDashboardDiagnostics,
|
getCodexDashboardDiagnostics,
|
||||||
getCodexRawConfig,
|
getCodexRawConfig,
|
||||||
|
patchCodexConfig,
|
||||||
saveCodexRawConfig,
|
saveCodexRawConfig,
|
||||||
} from '../services/codex-dashboard-service';
|
} from '../services/codex-dashboard-service';
|
||||||
|
|
||||||
@@ -56,4 +57,33 @@ router.put('/config/raw', async (req: Request, res: Response): Promise<void> =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.patch('/config/patch', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const body = req.body ?? {};
|
||||||
|
if (typeof body.kind !== 'string' || body.kind.trim().length === 0) {
|
||||||
|
res.status(400).json({ error: 'kind is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
body.expectedMtime !== undefined &&
|
||||||
|
(typeof body.expectedMtime !== 'number' || !Number.isFinite(body.expectedMtime))
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(await patchCodexConfig(body));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CodexRawConfigValidationError) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof CodexRawConfigConflictError) {
|
||||||
|
res.status(409).json({ error: error.message, mtime: error.mtime });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getCodexBinaryInfo,
|
getCodexBinaryInfo,
|
||||||
} from '../../targets/codex-detector';
|
} from '../../targets/codex-detector';
|
||||||
import type {
|
import type {
|
||||||
|
CodexConfigPatchInput,
|
||||||
|
CodexConfigPatchResult,
|
||||||
CodexDashboardDiagnostics,
|
CodexDashboardDiagnostics,
|
||||||
CodexFeatureFlagDiagnostics,
|
CodexFeatureFlagDiagnostics,
|
||||||
CodexMcpServerDiagnostics,
|
CodexMcpServerDiagnostics,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
TomlFileConflictError,
|
TomlFileConflictError,
|
||||||
TomlFileValidationError,
|
TomlFileValidationError,
|
||||||
probeTomlObjectFile,
|
probeTomlObjectFile,
|
||||||
|
stringifyTomlObject,
|
||||||
writeTomlFileAtomic,
|
writeTomlFileAtomic,
|
||||||
} from './compatible-cli-toml-file-service';
|
} from './compatible-cli-toml-file-service';
|
||||||
import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry';
|
import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry';
|
||||||
@@ -44,6 +47,32 @@ export {
|
|||||||
TomlFileValidationError as CodexRawConfigValidationError,
|
TomlFileValidationError as CodexRawConfigValidationError,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const KNOWN_CODEX_FEATURES = new Set([
|
||||||
|
'apps',
|
||||||
|
'apply_patch_freeform',
|
||||||
|
'codex_hooks',
|
||||||
|
'fast_mode',
|
||||||
|
'js_repl',
|
||||||
|
'multi_agent',
|
||||||
|
'personality',
|
||||||
|
'prevent_idle_sleep',
|
||||||
|
'runtime_metrics',
|
||||||
|
'shell_snapshot',
|
||||||
|
'shell_tool',
|
||||||
|
'smart_approvals',
|
||||||
|
'unified_exec',
|
||||||
|
'undo',
|
||||||
|
'web_search',
|
||||||
|
'web_search_cached',
|
||||||
|
'web_search_request',
|
||||||
|
]);
|
||||||
|
const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||||
|
const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']);
|
||||||
|
const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
||||||
|
const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']);
|
||||||
|
const PERSONALITY_VALUES = new Set(['default', 'pragmatic', 'concise', 'direct']);
|
||||||
|
const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'ask']);
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -60,10 +89,409 @@ function asNumber(value: unknown): number | null {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
|
function hasOwn(obj: object, key: string): boolean {
|
||||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||||
|
const existing = asObject(target[key]);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const next: Record<string, unknown> = {};
|
||||||
|
target[key] = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteIfEmpty(target: Record<string, unknown>, key: string) {
|
||||||
|
const value = asObject(target[key]);
|
||||||
|
if (value && Object.keys(value).length === 0) {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStringField(target: Record<string, unknown>, key: string, value: unknown) {
|
||||||
|
if (!isNonEmptyString(value)) {
|
||||||
|
delete target[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target[key] = value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnumStringField(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
allowedValues: Set<string>,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
if (!isNonEmptyString(value)) {
|
||||||
|
delete target[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
const currentValue = asString(target[key]);
|
||||||
|
if (!allowedValues.has(normalized) && normalized !== currentValue) {
|
||||||
|
throw new TomlFileValidationError(
|
||||||
|
`${label} must be one of: ${Array.from(allowedValues).join(', ')}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
target[key] = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBooleanField(target: Record<string, unknown>, key: string, value: unknown) {
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
delete target[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNumberField(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
options: { integer?: boolean; min?: number } = {}
|
||||||
|
) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
delete target[key];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.integer && !Number.isInteger(value)) {
|
||||||
|
throw new TomlFileValidationError(`${key} must be an integer.`);
|
||||||
|
}
|
||||||
|
if (typeof options.min === 'number' && value < options.min) {
|
||||||
|
throw new TomlFileValidationError(`${key} must be >= ${options.min}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
target[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(value: unknown, label: string): string[] | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new TomlFileValidationError(`${label} must be an array of strings.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
return normalized.length > 0 ? normalized : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPatchableToml(fileProbe: {
|
||||||
|
diagnostics: { parseError: string | null; readError: string | null };
|
||||||
|
config: Record<string, unknown> | null;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
if (fileProbe.diagnostics.readError) {
|
||||||
|
throw new TomlFileValidationError(fileProbe.diagnostics.readError);
|
||||||
|
}
|
||||||
|
if (fileProbe.diagnostics.parseError) {
|
||||||
|
throw new TomlFileValidationError(
|
||||||
|
'config.toml contains invalid TOML. Fix the raw file before using guided controls.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return asObject(fileProbe.config) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTopLevelSettingsPatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
values: Extract<CodexConfigPatchInput, { kind: 'top-level' }>['values']
|
||||||
|
) {
|
||||||
|
if (hasOwn(values, 'model')) setStringField(target, 'model', values.model);
|
||||||
|
if (hasOwn(values, 'modelReasoningEffort')) {
|
||||||
|
setEnumStringField(
|
||||||
|
target,
|
||||||
|
'model_reasoning_effort',
|
||||||
|
values.modelReasoningEffort,
|
||||||
|
MODEL_REASONING_EFFORT_VALUES,
|
||||||
|
'model_reasoning_effort'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'modelProvider')) {
|
||||||
|
setStringField(target, 'model_provider', values.modelProvider);
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'approvalPolicy')) {
|
||||||
|
setEnumStringField(
|
||||||
|
target,
|
||||||
|
'approval_policy',
|
||||||
|
values.approvalPolicy,
|
||||||
|
APPROVAL_POLICY_VALUES,
|
||||||
|
'approval_policy'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'sandboxMode')) {
|
||||||
|
setEnumStringField(
|
||||||
|
target,
|
||||||
|
'sandbox_mode',
|
||||||
|
values.sandboxMode,
|
||||||
|
SANDBOX_MODE_VALUES,
|
||||||
|
'sandbox_mode'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'webSearch')) {
|
||||||
|
setEnumStringField(target, 'web_search', values.webSearch, WEB_SEARCH_VALUES, 'web_search');
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'toolOutputTokenLimit')) {
|
||||||
|
setNumberField(target, 'tool_output_token_limit', values.toolOutputTokenLimit, {
|
||||||
|
integer: true,
|
||||||
|
min: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'personality')) {
|
||||||
|
setEnumStringField(
|
||||||
|
target,
|
||||||
|
'personality',
|
||||||
|
values.personality,
|
||||||
|
PERSONALITY_VALUES,
|
||||||
|
'personality'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProjectTrustPatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
input: Extract<CodexConfigPatchInput, { kind: 'project-trust' }>
|
||||||
|
) {
|
||||||
|
if (!isNonEmptyString(input.path)) {
|
||||||
|
throw new TomlFileValidationError('Project path is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedPath = expandPath(input.path.trim());
|
||||||
|
if (!path.isAbsolute(expandedPath)) {
|
||||||
|
throw new TomlFileValidationError('Project path must be absolute or use ~/... expansion.');
|
||||||
|
}
|
||||||
|
const canonicalPath = path.resolve(expandedPath);
|
||||||
|
const projects = ensureObject(target, 'projects');
|
||||||
|
|
||||||
|
if (!isNonEmptyString(input.trustLevel)) {
|
||||||
|
delete projects[canonicalPath];
|
||||||
|
deleteIfEmpty(target, 'projects');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustLevel = input.trustLevel.trim();
|
||||||
|
if (!PROJECT_TRUST_LEVEL_VALUES.has(trustLevel)) {
|
||||||
|
throw new TomlFileValidationError(
|
||||||
|
`trust_level must be one of: ${Array.from(PROJECT_TRUST_LEVEL_VALUES).join(', ')}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
projects[canonicalPath] = {
|
||||||
|
...(asObject(projects[canonicalPath]) ?? {}),
|
||||||
|
trust_level: trustLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFeaturePatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
input: Extract<CodexConfigPatchInput, { kind: 'feature' }>
|
||||||
|
) {
|
||||||
|
const feature = input.feature.trim();
|
||||||
|
const currentFeatures = asObject(target.features);
|
||||||
|
if (
|
||||||
|
!feature ||
|
||||||
|
(!KNOWN_CODEX_FEATURES.has(feature) && !(currentFeatures && hasOwn(currentFeatures, feature)))
|
||||||
|
) {
|
||||||
|
throw new TomlFileValidationError(`Unsupported feature key "${input.feature}".`);
|
||||||
|
}
|
||||||
|
if (input.enabled !== null && typeof input.enabled !== 'boolean') {
|
||||||
|
throw new TomlFileValidationError('Feature enabled must be boolean or null.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = ensureObject(target, 'features');
|
||||||
|
if (input.enabled === null) {
|
||||||
|
delete features[feature];
|
||||||
|
} else {
|
||||||
|
features[feature] = input.enabled;
|
||||||
|
}
|
||||||
|
deleteIfEmpty(target, 'features');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProfilePatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
input: Extract<CodexConfigPatchInput, { kind: 'profile' }>
|
||||||
|
) {
|
||||||
|
if (!isNonEmptyString(input.name)) {
|
||||||
|
throw new TomlFileValidationError('Profile name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileName = input.name.trim();
|
||||||
|
|
||||||
|
if (!['set-active', 'upsert', 'delete'].includes(input.action)) {
|
||||||
|
throw new TomlFileValidationError('Unsupported profile action.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'set-active') {
|
||||||
|
setStringField(target, 'profile', profileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = ensureObject(target, 'profiles');
|
||||||
|
if (input.action === 'delete') {
|
||||||
|
delete profiles[profileName];
|
||||||
|
if (asString(target.profile) === profileName) {
|
||||||
|
delete target.profile;
|
||||||
|
}
|
||||||
|
deleteIfEmpty(target, 'profiles');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProfile = { ...(asObject(profiles[profileName]) ?? {}) };
|
||||||
|
applyTopLevelSettingsPatch(nextProfile, input.values ?? {});
|
||||||
|
if (Object.keys(nextProfile).length === 0) {
|
||||||
|
throw new TomlFileValidationError('Profile patch must include at least one saved field.');
|
||||||
|
}
|
||||||
|
profiles[profileName] = nextProfile;
|
||||||
|
if (input.setAsActive === true) {
|
||||||
|
target.profile = profileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModelProviderPatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
input: Extract<CodexConfigPatchInput, { kind: 'model-provider' }>
|
||||||
|
) {
|
||||||
|
if (!isNonEmptyString(input.name)) {
|
||||||
|
throw new TomlFileValidationError('Model provider name is required.');
|
||||||
|
}
|
||||||
|
const providerName = input.name.trim();
|
||||||
|
const providers = ensureObject(target, 'model_providers');
|
||||||
|
|
||||||
|
if (!['upsert', 'delete'].includes(input.action)) {
|
||||||
|
throw new TomlFileValidationError('Unsupported model provider action.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'delete') {
|
||||||
|
delete providers[providerName];
|
||||||
|
if (asString(target.model_provider) === providerName) {
|
||||||
|
delete target.model_provider;
|
||||||
|
}
|
||||||
|
deleteIfEmpty(target, 'model_providers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = input.values;
|
||||||
|
if (!values) {
|
||||||
|
throw new TomlFileValidationError('Model provider values are required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProvider = { ...(asObject(providers[providerName]) ?? {}) };
|
||||||
|
if (hasOwn(values, 'displayName')) setStringField(nextProvider, 'name', values.displayName);
|
||||||
|
if (hasOwn(values, 'baseUrl')) setStringField(nextProvider, 'base_url', values.baseUrl);
|
||||||
|
if (hasOwn(values, 'envKey')) setStringField(nextProvider, 'env_key', values.envKey);
|
||||||
|
if (hasOwn(values, 'wireApi')) {
|
||||||
|
if (values.wireApi !== null && values.wireApi !== undefined && values.wireApi !== 'responses') {
|
||||||
|
throw new TomlFileValidationError('wire_api must be "responses" for Codex model providers.');
|
||||||
|
}
|
||||||
|
setStringField(nextProvider, 'wire_api', values.wireApi);
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'requiresOpenaiAuth')) {
|
||||||
|
setBooleanField(nextProvider, 'requires_openai_auth', values.requiresOpenaiAuth);
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'supportsWebsockets')) {
|
||||||
|
setBooleanField(nextProvider, 'supports_websockets', values.supportsWebsockets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(nextProvider).length === 0) {
|
||||||
|
throw new TomlFileValidationError(
|
||||||
|
'Model provider patch must include at least one saved field.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
providers[providerName] = nextProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMcpServerPatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
input: Extract<CodexConfigPatchInput, { kind: 'mcp-server' }>
|
||||||
|
) {
|
||||||
|
if (!isNonEmptyString(input.name)) {
|
||||||
|
throw new TomlFileValidationError('MCP server name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverName = input.name.trim();
|
||||||
|
const servers = ensureObject(target, 'mcp_servers');
|
||||||
|
|
||||||
|
if (!['upsert', 'delete'].includes(input.action)) {
|
||||||
|
throw new TomlFileValidationError('Unsupported MCP server action.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'delete') {
|
||||||
|
delete servers[serverName];
|
||||||
|
deleteIfEmpty(target, 'mcp_servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = input.values;
|
||||||
|
if (!values) {
|
||||||
|
throw new TomlFileValidationError('MCP server values are required.');
|
||||||
|
}
|
||||||
|
if (values.transport !== 'stdio' && values.transport !== 'streamable-http') {
|
||||||
|
throw new TomlFileValidationError('MCP transport must be "stdio" or "streamable-http".');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextServer = { ...(asObject(servers[serverName]) ?? {}) };
|
||||||
|
if (values.transport === 'stdio') {
|
||||||
|
if (!isNonEmptyString(values.command)) {
|
||||||
|
throw new TomlFileValidationError('Stdio MCP servers require a command.');
|
||||||
|
}
|
||||||
|
nextServer.command = values.command.trim();
|
||||||
|
const nextArgs = normalizeStringArray(values.args, 'args');
|
||||||
|
if (nextArgs === null) {
|
||||||
|
delete nextServer.args;
|
||||||
|
} else {
|
||||||
|
nextServer.args = nextArgs;
|
||||||
|
}
|
||||||
|
delete nextServer.url;
|
||||||
|
} else {
|
||||||
|
if (!isNonEmptyString(values.url)) {
|
||||||
|
throw new TomlFileValidationError('HTTP MCP servers require a URL.');
|
||||||
|
}
|
||||||
|
nextServer.url = values.url.trim();
|
||||||
|
delete nextServer.command;
|
||||||
|
delete nextServer.args;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled);
|
||||||
|
if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required);
|
||||||
|
if (hasOwn(values, 'startupTimeoutSec')) {
|
||||||
|
setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, {
|
||||||
|
integer: true,
|
||||||
|
min: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'toolTimeoutSec')) {
|
||||||
|
setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, {
|
||||||
|
integer: true,
|
||||||
|
min: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'enabledTools')) {
|
||||||
|
const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools');
|
||||||
|
if (nextEnabledTools === null) {
|
||||||
|
delete nextServer.enabled_tools;
|
||||||
|
} else {
|
||||||
|
nextServer.enabled_tools = nextEnabledTools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasOwn(values, 'disabledTools')) {
|
||||||
|
const nextDisabledTools = normalizeStringArray(values.disabledTools, 'disabledTools');
|
||||||
|
if (nextDisabledTools === null) {
|
||||||
|
delete nextServer.disabled_tools;
|
||||||
|
} else {
|
||||||
|
nextServer.disabled_tools = nextDisabledTools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servers[serverName] = nextServer;
|
||||||
|
}
|
||||||
|
|
||||||
function parseTransport(server: Record<string, unknown>): CodexMcpServerDiagnostics['transport'] {
|
function parseTransport(server: Record<string, unknown>): CodexMcpServerDiagnostics['transport'] {
|
||||||
if (asString(server.command)) return 'stdio';
|
if (asString(server.command)) return 'stdio';
|
||||||
if (asString(server.url)) return 'streamable-http';
|
if (asString(server.url)) return 'streamable-http';
|
||||||
@@ -294,13 +722,17 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
|
|||||||
supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo),
|
supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo),
|
||||||
},
|
},
|
||||||
file: fileProbe.diagnostics,
|
file: fileProbe.diagnostics,
|
||||||
|
workspacePath: process.cwd(),
|
||||||
config: {
|
config: {
|
||||||
model: asString(config?.model),
|
model: asString(config?.model),
|
||||||
|
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||||
modelProvider: asString(config?.model_provider),
|
modelProvider: asString(config?.model_provider),
|
||||||
activeProfile,
|
activeProfile,
|
||||||
approvalPolicy: asString(config?.approval_policy),
|
approvalPolicy: asString(config?.approval_policy),
|
||||||
sandboxMode: asString(config?.sandbox_mode),
|
sandboxMode: asString(config?.sandbox_mode),
|
||||||
webSearch: asString(config?.web_search),
|
webSearch: asString(config?.web_search),
|
||||||
|
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
|
||||||
|
personality: asString(config?.personality),
|
||||||
topLevelKeys,
|
topLevelKeys,
|
||||||
profileCount: profileNames.length,
|
profileCount: profileNames.length,
|
||||||
profileNames,
|
profileNames,
|
||||||
@@ -357,3 +789,57 @@ export async function saveCodexRawConfig(
|
|||||||
|
|
||||||
return { success: true, mtime: saved.mtime };
|
return { success: true, mtime: saved.mtime };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchCodexConfig(
|
||||||
|
input: CodexConfigPatchInput
|
||||||
|
): Promise<CodexConfigPatchResult> {
|
||||||
|
const paths = resolveCodexConfigPaths();
|
||||||
|
const fileProbe = await probeTomlObjectFile(
|
||||||
|
paths.configPath,
|
||||||
|
'Codex user config',
|
||||||
|
paths.configDisplayPath
|
||||||
|
);
|
||||||
|
const nextConfig = { ...assertPatchableToml(fileProbe) };
|
||||||
|
|
||||||
|
switch (input.kind) {
|
||||||
|
case 'top-level':
|
||||||
|
applyTopLevelSettingsPatch(nextConfig, input.values);
|
||||||
|
break;
|
||||||
|
case 'project-trust':
|
||||||
|
applyProjectTrustPatch(nextConfig, input);
|
||||||
|
break;
|
||||||
|
case 'feature':
|
||||||
|
applyFeaturePatch(nextConfig, input);
|
||||||
|
break;
|
||||||
|
case 'profile':
|
||||||
|
applyProfilePatch(nextConfig, input);
|
||||||
|
break;
|
||||||
|
case 'model-provider':
|
||||||
|
applyModelProviderPatch(nextConfig, input);
|
||||||
|
break;
|
||||||
|
case 'mcp-server':
|
||||||
|
applyMcpServerPatch(nextConfig, input);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new TomlFileValidationError('Unsupported Codex config patch.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = stringifyTomlObject(nextConfig);
|
||||||
|
const saved = await writeTomlFileAtomic({
|
||||||
|
filePath: paths.configPath,
|
||||||
|
rawText,
|
||||||
|
expectedMtime: input.expectedMtime,
|
||||||
|
fileLabel: 'config.toml',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
path: paths.configDisplayPath,
|
||||||
|
resolvedPath: paths.configPath,
|
||||||
|
exists: true,
|
||||||
|
mtime: saved.mtime,
|
||||||
|
rawText,
|
||||||
|
config: nextConfig,
|
||||||
|
parseError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { parse } from 'smol-toml';
|
import { parse, stringify } from 'smol-toml';
|
||||||
|
|
||||||
export interface TomlFileDiagnostics {
|
export interface TomlFileDiagnostics {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -92,6 +92,15 @@ export function parseTomlObjectText(
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stringifyTomlObject(config: Record<string, unknown>): string {
|
||||||
|
if (!isObject(config)) {
|
||||||
|
throw new TomlFileValidationError('config TOML root must be a table.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = stringify(config).trimEnd();
|
||||||
|
return text ? `${text}\n` : '';
|
||||||
|
}
|
||||||
|
|
||||||
export async function probeTomlObjectFile(
|
export async function probeTomlObjectFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
label: string,
|
label: string,
|
||||||
|
|||||||
@@ -146,11 +146,14 @@ export interface CodexSupportMatrixEntry {
|
|||||||
|
|
||||||
export interface CodexUserConfigDiagnostics {
|
export interface CodexUserConfigDiagnostics {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
|
modelReasoningEffort: string | null;
|
||||||
modelProvider: string | null;
|
modelProvider: string | null;
|
||||||
activeProfile: string | null;
|
activeProfile: string | null;
|
||||||
approvalPolicy: string | null;
|
approvalPolicy: string | null;
|
||||||
sandboxMode: string | null;
|
sandboxMode: string | null;
|
||||||
webSearch: string | null;
|
webSearch: string | null;
|
||||||
|
toolOutputTokenLimit: number | null;
|
||||||
|
personality: string | null;
|
||||||
topLevelKeys: string[];
|
topLevelKeys: string[];
|
||||||
profileCount: number;
|
profileCount: number;
|
||||||
profileNames: string[];
|
profileNames: string[];
|
||||||
@@ -169,6 +172,7 @@ export interface CodexUserConfigDiagnostics {
|
|||||||
export interface CodexDashboardDiagnostics {
|
export interface CodexDashboardDiagnostics {
|
||||||
binary: CodexBinaryDiagnostics;
|
binary: CodexBinaryDiagnostics;
|
||||||
file: CodexConfigFileDiagnostics;
|
file: CodexConfigFileDiagnostics;
|
||||||
|
workspacePath: string;
|
||||||
config: CodexUserConfigDiagnostics;
|
config: CodexUserConfigDiagnostics;
|
||||||
supportMatrix: CodexSupportMatrixEntry[];
|
supportMatrix: CodexSupportMatrixEntry[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
@@ -184,3 +188,83 @@ export interface CodexRawConfigResponse {
|
|||||||
config: Record<string, unknown> | null;
|
config: Record<string, unknown> | null;
|
||||||
parseError: string | null;
|
parseError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodexTopLevelSettingsPatch {
|
||||||
|
model?: string | null;
|
||||||
|
modelReasoningEffort?: string | null;
|
||||||
|
modelProvider?: string | null;
|
||||||
|
approvalPolicy?: string | null;
|
||||||
|
sandboxMode?: string | null;
|
||||||
|
webSearch?: string | null;
|
||||||
|
toolOutputTokenLimit?: number | null;
|
||||||
|
personality?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {}
|
||||||
|
|
||||||
|
export interface CodexModelProviderPatchValues {
|
||||||
|
displayName?: string | null;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
envKey?: string | null;
|
||||||
|
wireApi?: string | null;
|
||||||
|
requiresOpenaiAuth?: boolean | null;
|
||||||
|
supportsWebsockets?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexMcpServerPatchValues {
|
||||||
|
transport: 'stdio' | 'streamable-http';
|
||||||
|
command?: string | null;
|
||||||
|
args?: string[] | null;
|
||||||
|
url?: string | null;
|
||||||
|
enabled?: boolean | null;
|
||||||
|
required?: boolean | null;
|
||||||
|
startupTimeoutSec?: number | null;
|
||||||
|
toolTimeoutSec?: number | null;
|
||||||
|
enabledTools?: string[] | null;
|
||||||
|
disabledTools?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexConfigPatchInput =
|
||||||
|
| {
|
||||||
|
kind: 'top-level';
|
||||||
|
expectedMtime?: number;
|
||||||
|
values: CodexTopLevelSettingsPatch;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'project-trust';
|
||||||
|
expectedMtime?: number;
|
||||||
|
path: string;
|
||||||
|
trustLevel: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'feature';
|
||||||
|
expectedMtime?: number;
|
||||||
|
feature: string;
|
||||||
|
enabled: boolean | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'profile';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'set-active' | 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexProfilePatchValues;
|
||||||
|
setAsActive?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'model-provider';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexModelProviderPatchValues;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'mcp-server';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexMcpServerPatchValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CodexRawConfigValidationError,
|
CodexRawConfigValidationError,
|
||||||
getCodexDashboardDiagnostics,
|
getCodexDashboardDiagnostics,
|
||||||
getCodexRawConfig,
|
getCodexRawConfig,
|
||||||
|
patchCodexConfig,
|
||||||
resolveCodexConfigPaths,
|
resolveCodexConfigPaths,
|
||||||
saveCodexRawConfig,
|
saveCodexRawConfig,
|
||||||
summarizeCodexFeatureFlags,
|
summarizeCodexFeatureFlags,
|
||||||
@@ -262,4 +263,172 @@ bearer_token = "secret"
|
|||||||
})
|
})
|
||||||
).rejects.toThrow(CodexRawConfigConflictError);
|
).rejects.toThrow(CodexRawConfigConflictError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('patches top-level settings and project trust through structured controls', async () => {
|
||||||
|
const result = await patchCodexConfig({
|
||||||
|
kind: 'top-level',
|
||||||
|
values: {
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
modelReasoningEffort: 'high',
|
||||||
|
approvalPolicy: 'never',
|
||||||
|
sandboxMode: 'workspace-write',
|
||||||
|
webSearch: 'cached',
|
||||||
|
toolOutputTokenLimit: 12000,
|
||||||
|
personality: 'pragmatic',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await patchCodexConfig({
|
||||||
|
kind: 'project-trust',
|
||||||
|
path: '/tmp/workspace-a',
|
||||||
|
trustLevel: 'trusted',
|
||||||
|
expectedMtime: result.mtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnostics = await getCodexDashboardDiagnostics();
|
||||||
|
expect(diagnostics.config.model).toBe('gpt-5.4');
|
||||||
|
expect(diagnostics.config.modelReasoningEffort).toBe('high');
|
||||||
|
expect(diagnostics.config.toolOutputTokenLimit).toBe(12000);
|
||||||
|
expect(diagnostics.config.personality).toBe('pragmatic');
|
||||||
|
expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a');
|
||||||
|
expect(result.rawText).toContain('model = "gpt-5.4"');
|
||||||
|
expect(result.config?.model).toBe('gpt-5.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands home paths for project trust and rejects relative paths', async () => {
|
||||||
|
const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace');
|
||||||
|
const expanded = await patchCodexConfig({
|
||||||
|
kind: 'project-trust',
|
||||||
|
path: '~/codex-workspace',
|
||||||
|
trustLevel: 'trusted',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(expanded.rawText).toContain(`[projects."${homeWorkspacePath}"]`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
patchCodexConfig({
|
||||||
|
kind: 'project-trust',
|
||||||
|
path: './relative-workspace',
|
||||||
|
trustLevel: 'trusted',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(CodexRawConfigValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches profiles, providers, and mcp servers through structured controls', async () => {
|
||||||
|
const providerResult = await patchCodexConfig({
|
||||||
|
kind: 'model-provider',
|
||||||
|
action: 'upsert',
|
||||||
|
name: 'cliproxy',
|
||||||
|
values: {
|
||||||
|
displayName: 'CLIProxy',
|
||||||
|
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||||
|
envKey: 'CLIPROXY_API_KEY',
|
||||||
|
wireApi: 'responses',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileResult = await patchCodexConfig({
|
||||||
|
kind: 'profile',
|
||||||
|
action: 'upsert',
|
||||||
|
name: 'deep-review',
|
||||||
|
values: {
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
modelProvider: 'cliproxy',
|
||||||
|
modelReasoningEffort: 'xhigh',
|
||||||
|
},
|
||||||
|
setAsActive: true,
|
||||||
|
expectedMtime: providerResult.mtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
await patchCodexConfig({
|
||||||
|
kind: 'mcp-server',
|
||||||
|
action: 'upsert',
|
||||||
|
name: 'playwright',
|
||||||
|
values: {
|
||||||
|
transport: 'stdio',
|
||||||
|
command: 'npx',
|
||||||
|
args: ['@playwright/mcp@latest'],
|
||||||
|
enabled: true,
|
||||||
|
required: false,
|
||||||
|
startupTimeoutSec: 15,
|
||||||
|
toolTimeoutSec: 30,
|
||||||
|
},
|
||||||
|
expectedMtime: profileResult.mtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnostics = await getCodexDashboardDiagnostics();
|
||||||
|
expect(diagnostics.config.activeProfile).toBe('deep-review');
|
||||||
|
expect(diagnostics.config.modelProviderCount).toBe(1);
|
||||||
|
expect(diagnostics.config.mcpServerCount).toBe(1);
|
||||||
|
|
||||||
|
const raw = await getCodexRawConfig();
|
||||||
|
expect(raw.rawText).toContain('[profiles.deep-review]');
|
||||||
|
expect(raw.rawText).toContain('[model_providers.cliproxy]');
|
||||||
|
expect(raw.rawText).toContain('[mcp_servers.playwright]');
|
||||||
|
expect(profileResult.rawText).toContain('[profiles.deep-review]');
|
||||||
|
expect(profileResult.config?.profile).toBe('deep-review');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects structured patches when config.toml is invalid', async () => {
|
||||||
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
patchCodexConfig({
|
||||||
|
kind: 'feature',
|
||||||
|
feature: 'multi_agent',
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(CodexRawConfigValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes feature overrides when a feature is reset to inherited state', async () => {
|
||||||
|
const enabled = await patchCodexConfig({
|
||||||
|
kind: 'feature',
|
||||||
|
feature: 'multi_agent',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enabled.rawText).toContain('[features]');
|
||||||
|
expect(enabled.rawText).toContain('multi_agent = true');
|
||||||
|
|
||||||
|
const reset = await patchCodexConfig({
|
||||||
|
kind: 'feature',
|
||||||
|
feature: 'multi_agent',
|
||||||
|
enabled: null,
|
||||||
|
expectedMtime: enabled.mtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reset.rawText).not.toContain('[features]');
|
||||||
|
expect(reset.config?.features).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed structured patch payloads at runtime', async () => {
|
||||||
|
await expect(
|
||||||
|
patchCodexConfig({
|
||||||
|
kind: 'feature',
|
||||||
|
feature: 'multi_agent',
|
||||||
|
enabled: 'true' as unknown as boolean | null,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(CodexRawConfigValidationError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
patchCodexConfig({
|
||||||
|
kind: 'project-trust',
|
||||||
|
path: '~/codex-workspace',
|
||||||
|
trustLevel: 'always',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(CodexRawConfigValidationError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
patchCodexConfig({
|
||||||
|
kind: 'mcp-server',
|
||||||
|
action: 'upsert',
|
||||||
|
name: 'remote',
|
||||||
|
values: {
|
||||||
|
transport: 'http' as 'stdio' | 'streamable-http',
|
||||||
|
url: 'https://example.test/mcp',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrow(CodexRawConfigValidationError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
export interface CodexUserConfigDiagnostics {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
|
modelReasoningEffort: string | null;
|
||||||
modelProvider: string | null;
|
modelProvider: string | null;
|
||||||
activeProfile: string | null;
|
activeProfile: string | null;
|
||||||
approvalPolicy: string | null;
|
approvalPolicy: string | null;
|
||||||
sandboxMode: string | null;
|
sandboxMode: string | null;
|
||||||
webSearch: string | null;
|
webSearch: string | null;
|
||||||
|
toolOutputTokenLimit: number | null;
|
||||||
|
personality: string | null;
|
||||||
topLevelKeys: string[];
|
topLevelKeys: string[];
|
||||||
profileCount: number;
|
profileCount: number;
|
||||||
profileNames: string[];
|
profileNames: string[];
|
||||||
@@ -111,6 +114,7 @@ export interface CodexUserConfigDiagnostics {
|
|||||||
export interface CodexDashboardDiagnostics {
|
export interface CodexDashboardDiagnostics {
|
||||||
binary: CodexBinaryDiagnostics;
|
binary: CodexBinaryDiagnostics;
|
||||||
file: CodexConfigFileDiagnostics;
|
file: CodexConfigFileDiagnostics;
|
||||||
|
workspacePath: string;
|
||||||
config: CodexUserConfigDiagnostics;
|
config: CodexUserConfigDiagnostics;
|
||||||
supportMatrix: CodexSupportMatrixEntry[];
|
supportMatrix: CodexSupportMatrixEntry[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
@@ -126,3 +130,83 @@ export interface CodexRawConfigResponse {
|
|||||||
config: Record<string, unknown> | null;
|
config: Record<string, unknown> | null;
|
||||||
parseError: string | null;
|
parseError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodexTopLevelSettingsPatch {
|
||||||
|
model?: string | null;
|
||||||
|
modelReasoningEffort?: string | null;
|
||||||
|
modelProvider?: string | null;
|
||||||
|
approvalPolicy?: string | null;
|
||||||
|
sandboxMode?: string | null;
|
||||||
|
webSearch?: string | null;
|
||||||
|
toolOutputTokenLimit?: number | null;
|
||||||
|
personality?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexProfilePatchValues = CodexTopLevelSettingsPatch;
|
||||||
|
|
||||||
|
export interface CodexModelProviderPatchValues {
|
||||||
|
displayName?: string | null;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
envKey?: string | null;
|
||||||
|
wireApi?: string | null;
|
||||||
|
requiresOpenaiAuth?: boolean | null;
|
||||||
|
supportsWebsockets?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexMcpServerPatchValues {
|
||||||
|
transport: 'stdio' | 'streamable-http';
|
||||||
|
command?: string | null;
|
||||||
|
args?: string[] | null;
|
||||||
|
url?: string | null;
|
||||||
|
enabled?: boolean | null;
|
||||||
|
required?: boolean | null;
|
||||||
|
startupTimeoutSec?: number | null;
|
||||||
|
toolTimeoutSec?: number | null;
|
||||||
|
enabledTools?: string[] | null;
|
||||||
|
disabledTools?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodexConfigPatchInput =
|
||||||
|
| {
|
||||||
|
kind: 'top-level';
|
||||||
|
expectedMtime?: number;
|
||||||
|
values: CodexTopLevelSettingsPatch;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'project-trust';
|
||||||
|
expectedMtime?: number;
|
||||||
|
path: string;
|
||||||
|
trustLevel: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'feature';
|
||||||
|
expectedMtime?: number;
|
||||||
|
feature: string;
|
||||||
|
enabled: boolean | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'profile';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'set-active' | 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexProfilePatchValues;
|
||||||
|
setAsActive?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'model-provider';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexModelProviderPatchValues;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'mcp-server';
|
||||||
|
expectedMtime?: number;
|
||||||
|
action: 'upsert' | 'delete';
|
||||||
|
name: string;
|
||||||
|
values?: CodexMcpServerPatchValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { useMemo } from 'react';
|
|||||||
import { parse as parseToml } from 'smol-toml';
|
import { parse as parseToml } from 'smol-toml';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
||||||
import type { CodexDashboardDiagnostics, CodexRawConfigResponse } from './use-codex-types';
|
import type {
|
||||||
|
CodexConfigPatchInput,
|
||||||
|
CodexConfigPatchResult,
|
||||||
|
CodexDashboardDiagnostics,
|
||||||
|
CodexRawConfigResponse,
|
||||||
|
} from './use-codex-types';
|
||||||
|
|
||||||
type CodexRawConfig = CodexRawConfigResponse;
|
type CodexRawConfig = CodexRawConfigResponse;
|
||||||
|
|
||||||
@@ -16,6 +21,8 @@ interface SaveCodexRawConfigResponse {
|
|||||||
mtime: number;
|
mtime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PatchCodexConfigResponse = CodexConfigPatchResult;
|
||||||
|
|
||||||
function parseCodexRawConfigText(rawText: string): {
|
function parseCodexRawConfigText(rawText: string): {
|
||||||
config: Record<string, unknown> | null;
|
config: Record<string, unknown> | null;
|
||||||
parseError: string | null;
|
parseError: string | null;
|
||||||
@@ -69,6 +76,21 @@ async function saveCodexRawConfig(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patchCodexConfig(data: CodexConfigPatchInput): Promise<PatchCodexConfigResponse> {
|
||||||
|
const res = await fetch(withApiBase('/codex/config/patch'), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.status === 409) throw new ApiConflictError('Codex config changed externally');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new Error(payload?.error || 'Failed to patch Codex config');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export function useCodex() {
|
export function useCodex() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -105,6 +127,14 @@ export function useCodex() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const patchConfigMutation = useMutation({
|
||||||
|
mutationFn: patchCodexConfig,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.setQueryData<CodexRawConfig>(['codex-raw-config'], result);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['codex-diagnostics'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
diagnostics: diagnosticsQuery.data,
|
diagnostics: diagnosticsQuery.data,
|
||||||
@@ -120,6 +150,9 @@ export function useCodex() {
|
|||||||
saveRawConfig: saveRawConfigMutation.mutate,
|
saveRawConfig: saveRawConfigMutation.mutate,
|
||||||
saveRawConfigAsync: saveRawConfigMutation.mutateAsync,
|
saveRawConfigAsync: saveRawConfigMutation.mutateAsync,
|
||||||
isSavingRawConfig: saveRawConfigMutation.isPending,
|
isSavingRawConfig: saveRawConfigMutation.isPending,
|
||||||
|
patchConfig: patchConfigMutation.mutate,
|
||||||
|
patchConfigAsync: patchConfigMutation.mutateAsync,
|
||||||
|
isPatchingConfig: patchConfigMutation.isPending,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
diagnosticsQuery.data,
|
diagnosticsQuery.data,
|
||||||
@@ -133,6 +166,9 @@ export function useCodex() {
|
|||||||
saveRawConfigMutation.mutate,
|
saveRawConfigMutation.mutate,
|
||||||
saveRawConfigMutation.mutateAsync,
|
saveRawConfigMutation.mutateAsync,
|
||||||
saveRawConfigMutation.isPending,
|
saveRawConfigMutation.isPending,
|
||||||
|
patchConfigMutation.mutate,
|
||||||
|
patchConfigMutation.mutateAsync,
|
||||||
|
patchConfigMutation.isPending,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { parse as parseToml } from 'smol-toml';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileWarning,
|
|
||||||
Folder,
|
Folder,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Info,
|
Info,
|
||||||
@@ -19,7 +18,13 @@ import {
|
|||||||
import { useCodex } from '@/hooks/use-codex';
|
import { useCodex } from '@/hooks/use-codex';
|
||||||
import { isApiConflictError } from '@/lib/api-client';
|
import { isApiConflictError } from '@/lib/api-client';
|
||||||
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
||||||
import { UsageCommand } from '@/components/cliproxy/provider-editor/usage-command';
|
import { CodexFeaturesCard } from '@/components/compatible-cli/codex-features-card';
|
||||||
|
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
|
||||||
|
import { CodexModelProvidersCard } from '@/components/compatible-cli/codex-model-providers-card';
|
||||||
|
import { CodexProfilesCard } from '@/components/compatible-cli/codex-profiles-card';
|
||||||
|
import { CodexProjectTrustCard } from '@/components/compatible-cli/codex-project-trust-card';
|
||||||
|
import { CodexTopLevelControlsCard } from '@/components/compatible-cli/codex-top-level-controls-card';
|
||||||
|
import { QuickCommands } from '@/components/shared';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -33,6 +38,15 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
KNOWN_CODEX_FEATURES,
|
||||||
|
readCodexFeatureState,
|
||||||
|
readCodexMcpServers,
|
||||||
|
readCodexModelProviders,
|
||||||
|
readCodexProfiles,
|
||||||
|
readCodexProjectTrust,
|
||||||
|
readCodexTopLevelSettings,
|
||||||
|
} from '@/lib/codex-config';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const DEFAULT_CODEX_DOC_LINKS = [
|
const DEFAULT_CODEX_DOC_LINKS = [
|
||||||
@@ -157,9 +171,12 @@ export function CodexPage() {
|
|||||||
refetchDiagnostics,
|
refetchDiagnostics,
|
||||||
rawConfig,
|
rawConfig,
|
||||||
rawConfigLoading,
|
rawConfigLoading,
|
||||||
|
rawConfigError,
|
||||||
refetchRawConfig,
|
refetchRawConfig,
|
||||||
saveRawConfigAsync,
|
saveRawConfigAsync,
|
||||||
isSavingRawConfig,
|
isSavingRawConfig,
|
||||||
|
patchConfigAsync,
|
||||||
|
isPatchingConfig,
|
||||||
} = useCodex();
|
} = useCodex();
|
||||||
|
|
||||||
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
|
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
|
||||||
@@ -170,6 +187,34 @@ export function CodexPage() {
|
|||||||
const rawEditorValidation = rawEditorParsed.valid
|
const rawEditorValidation = rawEditorParsed.valid
|
||||||
? { valid: true as const }
|
? { valid: true as const }
|
||||||
: { valid: false as const, error: rawEditorParsed.error };
|
: { valid: false as const, error: rawEditorParsed.error };
|
||||||
|
const controlsConfig = rawConfig?.config ?? null;
|
||||||
|
const structuredControlsDisabled =
|
||||||
|
rawConfigLoading || !rawConfig || rawConfigDirty || rawConfig?.parseError !== null;
|
||||||
|
const controlsDisabledReason = rawConfigError
|
||||||
|
? 'Structured controls unavailable: failed to load the current config.toml.'
|
||||||
|
: rawConfigDirty
|
||||||
|
? rawEditorValidation.valid
|
||||||
|
? 'Save or discard raw TOML edits before using structured controls.'
|
||||||
|
: 'Fix or discard raw TOML edits before using structured controls.'
|
||||||
|
: rawConfig?.parseError
|
||||||
|
? `Structured controls disabled: ${rawConfig.parseError}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const topLevelSettings = useMemo(
|
||||||
|
() => readCodexTopLevelSettings(controlsConfig),
|
||||||
|
[controlsConfig]
|
||||||
|
);
|
||||||
|
const projectTrustEntries = useMemo(
|
||||||
|
() => readCodexProjectTrust(controlsConfig),
|
||||||
|
[controlsConfig]
|
||||||
|
);
|
||||||
|
const profileEntries = useMemo(() => readCodexProfiles(controlsConfig), [controlsConfig]);
|
||||||
|
const modelProviderEntries = useMemo(
|
||||||
|
() => readCodexModelProviders(controlsConfig),
|
||||||
|
[controlsConfig]
|
||||||
|
);
|
||||||
|
const mcpServerEntries = useMemo(() => readCodexMcpServers(controlsConfig), [controlsConfig]);
|
||||||
|
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
|
||||||
|
|
||||||
const setRawEditorDraftText = (nextText: string) => {
|
const setRawEditorDraftText = (nextText: string) => {
|
||||||
if (nextText === rawBaseText) {
|
if (nextText === rawBaseText) {
|
||||||
@@ -206,6 +251,26 @@ export function CodexPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runConfigPatch = async (
|
||||||
|
patch: Parameters<typeof patchConfigAsync>[0],
|
||||||
|
successMessage: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await patchConfigAsync({
|
||||||
|
...patch,
|
||||||
|
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
|
||||||
|
});
|
||||||
|
setRawDraftText(null);
|
||||||
|
toast.success(successMessage);
|
||||||
|
} catch (error) {
|
||||||
|
if (isApiConflictError(error)) {
|
||||||
|
toast.error('config.toml changed externally. Refresh and retry.');
|
||||||
|
} else {
|
||||||
|
toast.error((error as Error).message || 'Failed to update Codex config.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderOverview = () => {
|
const renderOverview = () => {
|
||||||
if (diagnosticsLoading) {
|
if (diagnosticsLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -242,7 +307,7 @@ export function CodexPage() {
|
|||||||
<div className="shrink-0 px-4 pt-4">
|
<div className="shrink-0 px-4 pt-4">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="routing">Runtime & Routing</TabsTrigger>
|
<TabsTrigger value="controls">Control Center</TabsTrigger>
|
||||||
<TabsTrigger value="docs">Docs</TabsTrigger>
|
<TabsTrigger value="docs">Docs</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,30 +489,31 @@ export function CodexPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{diagnostics.warnings.length > 0 && (
|
<QuickCommands
|
||||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
snippets={[
|
||||||
<CardHeader className="pb-2">
|
{
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
label: 'Native Codex',
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
command: 'ccs-codex',
|
||||||
Warnings
|
description: 'Launch the native Codex runtime alias.',
|
||||||
</CardTitle>
|
},
|
||||||
</CardHeader>
|
{
|
||||||
<CardContent className="space-y-1.5">
|
label: 'Short alias',
|
||||||
{diagnostics.warnings.map((warning) => (
|
command: 'ccsx',
|
||||||
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
description: 'Launch the short Codex runtime alias.',
|
||||||
- {warning}
|
},
|
||||||
</p>
|
{
|
||||||
))}
|
label: 'Target override',
|
||||||
</CardContent>
|
command: 'ccs codex --target codex "your prompt"',
|
||||||
</Card>
|
description: 'Run a CCS profile on the native Codex target.',
|
||||||
)}
|
},
|
||||||
</div>
|
{
|
||||||
</ScrollArea>
|
label: 'Workspace trust',
|
||||||
</TabsContent>
|
command: `codex --profile ${diagnostics.config.activeProfile || 'default'}`,
|
||||||
|
description: 'Inspect the active profile directly in native Codex.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<TabsContent value="routing" className={tabContentClassName}>
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="space-y-4 pr-1">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
@@ -507,46 +573,146 @@ export function CodexPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
{diagnostics.warnings.length > 0 && (
|
||||||
<CardHeader className="pb-2">
|
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
<CardTitle className="text-base">Quick usage</CardTitle>
|
<CardHeader className="pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<CardContent className="space-y-3">
|
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||||
<UsageCommand label="Codex alias (explicit)" command="ccs-codex" />
|
Warnings
|
||||||
<UsageCommand label="Codex alias (short)" command="ccsx" />
|
</CardTitle>
|
||||||
<UsageCommand
|
</CardHeader>
|
||||||
label="Run a profile on native Codex"
|
<CardContent className="space-y-1.5">
|
||||||
command='ccs codex --target codex "your prompt"'
|
{diagnostics.warnings.map((warning) => (
|
||||||
/>
|
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
<UsageCommand
|
- {warning}
|
||||||
label="Routed profile example"
|
</p>
|
||||||
command='ccs mycodex --target codex "your prompt"'
|
))}
|
||||||
/>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="controls" className={tabContentClassName}>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-4 pr-1">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<FileWarning className="h-4 w-4" />
|
<Route className="h-4 w-4" />
|
||||||
What this editor affects
|
Structured controls boundary
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
Edits here affect native Codex sessions first because this is the user-layer{' '}
|
Guided controls write only the user-layer <code>config.toml</code>. They do
|
||||||
<code>config.toml</code>.
|
not model the full effective Codex runtime once trusted repo layers and CCS
|
||||||
|
transient <code>-c</code> overrides are involved.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
CCS-routed Codex launches may override provider-related keys transiently and
|
Structured saves normalize TOML formatting and strip comments. Use the raw
|
||||||
inject <code>CCS_CODEX_API_KEY</code>.
|
editor on the right when exact layout matters.
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
That means the file is not a complete source of truth for every routed Codex
|
|
||||||
launch you start from CCS.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<CodexTopLevelControlsCard
|
||||||
|
values={topLevelSettings}
|
||||||
|
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
||||||
|
disabled={structuredControlsDisabled}
|
||||||
|
disabledReason={controlsDisabledReason}
|
||||||
|
saving={isPatchingConfig}
|
||||||
|
onSave={(values) =>
|
||||||
|
runConfigPatch({ kind: 'top-level', values }, 'Saved top-level Codex settings.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexProjectTrustCard
|
||||||
|
workspacePath={diagnostics.workspacePath}
|
||||||
|
entries={projectTrustEntries}
|
||||||
|
disabled={structuredControlsDisabled}
|
||||||
|
disabledReason={controlsDisabledReason}
|
||||||
|
saving={isPatchingConfig}
|
||||||
|
onSave={(projectPath, trustLevel) =>
|
||||||
|
runConfigPatch(
|
||||||
|
{ kind: 'project-trust', path: projectPath, trustLevel },
|
||||||
|
trustLevel ? 'Saved project trust entry.' : 'Removed project trust entry.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexProfilesCard
|
||||||
|
activeProfile={diagnostics.config.activeProfile}
|
||||||
|
entries={profileEntries}
|
||||||
|
providerNames={modelProviderEntries.map((entry) => entry.name)}
|
||||||
|
disabled={structuredControlsDisabled}
|
||||||
|
disabledReason={controlsDisabledReason}
|
||||||
|
saving={isPatchingConfig}
|
||||||
|
onSave={(name, values, setAsActive) =>
|
||||||
|
runConfigPatch(
|
||||||
|
{ kind: 'profile', action: 'upsert', name, values, setAsActive },
|
||||||
|
'Saved profile.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDelete={(name) =>
|
||||||
|
runConfigPatch({ kind: 'profile', action: 'delete', name }, 'Deleted profile.')
|
||||||
|
}
|
||||||
|
onSetActive={(name) =>
|
||||||
|
runConfigPatch(
|
||||||
|
{ kind: 'profile', action: 'set-active', name },
|
||||||
|
'Set active profile.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexModelProvidersCard
|
||||||
|
entries={modelProviderEntries}
|
||||||
|
disabled={structuredControlsDisabled}
|
||||||
|
disabledReason={controlsDisabledReason}
|
||||||
|
saving={isPatchingConfig}
|
||||||
|
onSave={(name, values) =>
|
||||||
|
runConfigPatch(
|
||||||
|
{ kind: 'model-provider', action: 'upsert', name, values },
|
||||||
|
'Saved model provider.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDelete={(name) =>
|
||||||
|
runConfigPatch(
|
||||||
|
{ kind: 'model-provider', action: 'delete', name },
|
||||||
|
'Deleted model provider.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexMcpServersCard
|
||||||
|
entries={mcpServerEntries}
|
||||||
|
disabled={structuredControlsDisabled}
|
||||||
|
disabledReason={controlsDisabledReason}
|
||||||
|
saving={isPatchingConfig}
|
||||||
|
onSave={(name, values) =>
|
||||||
|
runConfigPatch(
|
||||||
|
{ kind: 'mcp-server', action: 'upsert', name, values },
|
||||||
|
'Saved MCP server.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDelete={(name) =>
|
||||||
|
runConfigPatch(
|
||||||
|
{ kind: 'mcp-server', action: 'delete', name },
|
||||||
|
'Deleted MCP server.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexFeaturesCard
|
||||||
|
catalog={KNOWN_CODEX_FEATURES}
|
||||||
|
state={featureState}
|
||||||
|
disabled={structuredControlsDisabled}
|
||||||
|
disabledReason={controlsDisabledReason}
|
||||||
|
onToggle={(feature, enabled) =>
|
||||||
|
runConfigPatch({ kind: 'feature', feature, enabled }, 'Saved feature toggle.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -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