mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 20:14:34 +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:
1 parent
3c52b1ab6d
commit
b47aa0d28d
18 files changed
+2959
-57
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
CodexRawConfigValidationError,
|
||||
getCodexDashboardDiagnostics,
|
||||
getCodexRawConfig,
|
||||
patchCodexConfig,
|
||||
saveCodexRawConfig,
|
||||
} from '../services/codex-dashboard-service';
|
||||
|
||||
@@ -56,4 +57,33 @@ router.put('/config/raw', async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/config/patch', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body ?? {};
|
||||
if (typeof body.kind !== 'string' || body.kind.trim().length === 0) {
|
||||
res.status(400).json({ error: 'kind is required.' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
body.expectedMtime !== undefined &&
|
||||
(typeof body.expectedMtime !== 'number' || !Number.isFinite(body.expectedMtime))
|
||||
) {
|
||||
res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await patchCodexConfig(body));
|
||||
} catch (error) {
|
||||
if (error instanceof CodexRawConfigValidationError) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof CodexRawConfigConflictError) {
|
||||
res.status(409).json({ error: error.message, mtime: error.mtime });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getCodexBinaryInfo,
|
||||
} from '../../targets/codex-detector';
|
||||
import type {
|
||||
CodexConfigPatchInput,
|
||||
CodexConfigPatchResult,
|
||||
CodexDashboardDiagnostics,
|
||||
CodexFeatureFlagDiagnostics,
|
||||
CodexMcpServerDiagnostics,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
TomlFileConflictError,
|
||||
TomlFileValidationError,
|
||||
probeTomlObjectFile,
|
||||
stringifyTomlObject,
|
||||
writeTomlFileAtomic,
|
||||
} from './compatible-cli-toml-file-service';
|
||||
import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry';
|
||||
@@ -44,6 +47,32 @@ export {
|
||||
TomlFileValidationError as CodexRawConfigValidationError,
|
||||
};
|
||||
|
||||
const KNOWN_CODEX_FEATURES = new Set([
|
||||
'apps',
|
||||
'apply_patch_freeform',
|
||||
'codex_hooks',
|
||||
'fast_mode',
|
||||
'js_repl',
|
||||
'multi_agent',
|
||||
'personality',
|
||||
'prevent_idle_sleep',
|
||||
'runtime_metrics',
|
||||
'shell_snapshot',
|
||||
'shell_tool',
|
||||
'smart_approvals',
|
||||
'unified_exec',
|
||||
'undo',
|
||||
'web_search',
|
||||
'web_search_cached',
|
||||
'web_search_request',
|
||||
]);
|
||||
const MODEL_REASONING_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
const APPROVAL_POLICY_VALUES = new Set(['on-request', 'never', 'untrusted']);
|
||||
const SANDBOX_MODE_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
||||
const WEB_SEARCH_VALUES = new Set(['cached', 'live', 'disabled']);
|
||||
const PERSONALITY_VALUES = new Set(['default', 'pragmatic', 'concise', 'direct']);
|
||||
const PROJECT_TRUST_LEVEL_VALUES = new Set(['trusted', 'ask']);
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -60,10 +89,409 @@ function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
|
||||
function hasOwn(obj: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObject(target[key]);
|
||||
if (existing) return existing;
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
target[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function deleteIfEmpty(target: Record<string, unknown>, key: string) {
|
||||
const value = asObject(target[key]);
|
||||
if (value && Object.keys(value).length === 0) {
|
||||
delete target[key];
|
||||
}
|
||||
}
|
||||
|
||||
function setStringField(target: Record<string, unknown>, key: string, value: unknown) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
target[key] = value.trim();
|
||||
}
|
||||
|
||||
function setEnumStringField(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
allowedValues: Set<string>,
|
||||
label: string
|
||||
) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
const currentValue = asString(target[key]);
|
||||
if (!allowedValues.has(normalized) && normalized !== currentValue) {
|
||||
throw new TomlFileValidationError(
|
||||
`${label} must be one of: ${Array.from(allowedValues).join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
target[key] = normalized;
|
||||
}
|
||||
|
||||
function setBooleanField(target: Record<string, unknown>, key: string, value: unknown) {
|
||||
if (typeof value !== 'boolean') {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
target[key] = value;
|
||||
}
|
||||
|
||||
function setNumberField(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
options: { integer?: boolean; min?: number } = {}
|
||||
) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.integer && !Number.isInteger(value)) {
|
||||
throw new TomlFileValidationError(`${key} must be an integer.`);
|
||||
}
|
||||
if (typeof options.min === 'number' && value < options.min) {
|
||||
throw new TomlFileValidationError(`${key} must be >= ${options.min}.`);
|
||||
}
|
||||
|
||||
target[key] = value;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown, label: string): string[] | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TomlFileValidationError(`${label} must be an array of strings.`);
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry) => entry.length > 0);
|
||||
return normalized.length > 0 ? normalized : [];
|
||||
}
|
||||
|
||||
function assertPatchableToml(fileProbe: {
|
||||
diagnostics: { parseError: string | null; readError: string | null };
|
||||
config: Record<string, unknown> | null;
|
||||
}): Record<string, unknown> {
|
||||
if (fileProbe.diagnostics.readError) {
|
||||
throw new TomlFileValidationError(fileProbe.diagnostics.readError);
|
||||
}
|
||||
if (fileProbe.diagnostics.parseError) {
|
||||
throw new TomlFileValidationError(
|
||||
'config.toml contains invalid TOML. Fix the raw file before using guided controls.'
|
||||
);
|
||||
}
|
||||
return asObject(fileProbe.config) ?? {};
|
||||
}
|
||||
|
||||
function applyTopLevelSettingsPatch(
|
||||
target: Record<string, unknown>,
|
||||
values: Extract<CodexConfigPatchInput, { kind: 'top-level' }>['values']
|
||||
) {
|
||||
if (hasOwn(values, 'model')) setStringField(target, 'model', values.model);
|
||||
if (hasOwn(values, 'modelReasoningEffort')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'model_reasoning_effort',
|
||||
values.modelReasoningEffort,
|
||||
MODEL_REASONING_EFFORT_VALUES,
|
||||
'model_reasoning_effort'
|
||||
);
|
||||
}
|
||||
if (hasOwn(values, 'modelProvider')) {
|
||||
setStringField(target, 'model_provider', values.modelProvider);
|
||||
}
|
||||
if (hasOwn(values, 'approvalPolicy')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'approval_policy',
|
||||
values.approvalPolicy,
|
||||
APPROVAL_POLICY_VALUES,
|
||||
'approval_policy'
|
||||
);
|
||||
}
|
||||
if (hasOwn(values, 'sandboxMode')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'sandbox_mode',
|
||||
values.sandboxMode,
|
||||
SANDBOX_MODE_VALUES,
|
||||
'sandbox_mode'
|
||||
);
|
||||
}
|
||||
if (hasOwn(values, 'webSearch')) {
|
||||
setEnumStringField(target, 'web_search', values.webSearch, WEB_SEARCH_VALUES, 'web_search');
|
||||
}
|
||||
if (hasOwn(values, 'toolOutputTokenLimit')) {
|
||||
setNumberField(target, 'tool_output_token_limit', values.toolOutputTokenLimit, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
}
|
||||
if (hasOwn(values, 'personality')) {
|
||||
setEnumStringField(
|
||||
target,
|
||||
'personality',
|
||||
values.personality,
|
||||
PERSONALITY_VALUES,
|
||||
'personality'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applyProjectTrustPatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'project-trust' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.path)) {
|
||||
throw new TomlFileValidationError('Project path is required.');
|
||||
}
|
||||
|
||||
const expandedPath = expandPath(input.path.trim());
|
||||
if (!path.isAbsolute(expandedPath)) {
|
||||
throw new TomlFileValidationError('Project path must be absolute or use ~/... expansion.');
|
||||
}
|
||||
const canonicalPath = path.resolve(expandedPath);
|
||||
const projects = ensureObject(target, 'projects');
|
||||
|
||||
if (!isNonEmptyString(input.trustLevel)) {
|
||||
delete projects[canonicalPath];
|
||||
deleteIfEmpty(target, 'projects');
|
||||
return;
|
||||
}
|
||||
|
||||
const trustLevel = input.trustLevel.trim();
|
||||
if (!PROJECT_TRUST_LEVEL_VALUES.has(trustLevel)) {
|
||||
throw new TomlFileValidationError(
|
||||
`trust_level must be one of: ${Array.from(PROJECT_TRUST_LEVEL_VALUES).join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
projects[canonicalPath] = {
|
||||
...(asObject(projects[canonicalPath]) ?? {}),
|
||||
trust_level: trustLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFeaturePatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'feature' }>
|
||||
) {
|
||||
const feature = input.feature.trim();
|
||||
const currentFeatures = asObject(target.features);
|
||||
if (
|
||||
!feature ||
|
||||
(!KNOWN_CODEX_FEATURES.has(feature) && !(currentFeatures && hasOwn(currentFeatures, feature)))
|
||||
) {
|
||||
throw new TomlFileValidationError(`Unsupported feature key "${input.feature}".`);
|
||||
}
|
||||
if (input.enabled !== null && typeof input.enabled !== 'boolean') {
|
||||
throw new TomlFileValidationError('Feature enabled must be boolean or null.');
|
||||
}
|
||||
|
||||
const features = ensureObject(target, 'features');
|
||||
if (input.enabled === null) {
|
||||
delete features[feature];
|
||||
} else {
|
||||
features[feature] = input.enabled;
|
||||
}
|
||||
deleteIfEmpty(target, 'features');
|
||||
}
|
||||
|
||||
function applyProfilePatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'profile' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.name)) {
|
||||
throw new TomlFileValidationError('Profile name is required.');
|
||||
}
|
||||
|
||||
const profileName = input.name.trim();
|
||||
|
||||
if (!['set-active', 'upsert', 'delete'].includes(input.action)) {
|
||||
throw new TomlFileValidationError('Unsupported profile action.');
|
||||
}
|
||||
|
||||
if (input.action === 'set-active') {
|
||||
setStringField(target, 'profile', profileName);
|
||||
return;
|
||||
}
|
||||
|
||||
const profiles = ensureObject(target, 'profiles');
|
||||
if (input.action === 'delete') {
|
||||
delete profiles[profileName];
|
||||
if (asString(target.profile) === profileName) {
|
||||
delete target.profile;
|
||||
}
|
||||
deleteIfEmpty(target, 'profiles');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProfile = { ...(asObject(profiles[profileName]) ?? {}) };
|
||||
applyTopLevelSettingsPatch(nextProfile, input.values ?? {});
|
||||
if (Object.keys(nextProfile).length === 0) {
|
||||
throw new TomlFileValidationError('Profile patch must include at least one saved field.');
|
||||
}
|
||||
profiles[profileName] = nextProfile;
|
||||
if (input.setAsActive === true) {
|
||||
target.profile = profileName;
|
||||
}
|
||||
}
|
||||
|
||||
function applyModelProviderPatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'model-provider' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.name)) {
|
||||
throw new TomlFileValidationError('Model provider name is required.');
|
||||
}
|
||||
const providerName = input.name.trim();
|
||||
const providers = ensureObject(target, 'model_providers');
|
||||
|
||||
if (!['upsert', 'delete'].includes(input.action)) {
|
||||
throw new TomlFileValidationError('Unsupported model provider action.');
|
||||
}
|
||||
|
||||
if (input.action === 'delete') {
|
||||
delete providers[providerName];
|
||||
if (asString(target.model_provider) === providerName) {
|
||||
delete target.model_provider;
|
||||
}
|
||||
deleteIfEmpty(target, 'model_providers');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = input.values;
|
||||
if (!values) {
|
||||
throw new TomlFileValidationError('Model provider values are required.');
|
||||
}
|
||||
|
||||
const nextProvider = { ...(asObject(providers[providerName]) ?? {}) };
|
||||
if (hasOwn(values, 'displayName')) setStringField(nextProvider, 'name', values.displayName);
|
||||
if (hasOwn(values, 'baseUrl')) setStringField(nextProvider, 'base_url', values.baseUrl);
|
||||
if (hasOwn(values, 'envKey')) setStringField(nextProvider, 'env_key', values.envKey);
|
||||
if (hasOwn(values, 'wireApi')) {
|
||||
if (values.wireApi !== null && values.wireApi !== undefined && values.wireApi !== 'responses') {
|
||||
throw new TomlFileValidationError('wire_api must be "responses" for Codex model providers.');
|
||||
}
|
||||
setStringField(nextProvider, 'wire_api', values.wireApi);
|
||||
}
|
||||
if (hasOwn(values, 'requiresOpenaiAuth')) {
|
||||
setBooleanField(nextProvider, 'requires_openai_auth', values.requiresOpenaiAuth);
|
||||
}
|
||||
if (hasOwn(values, 'supportsWebsockets')) {
|
||||
setBooleanField(nextProvider, 'supports_websockets', values.supportsWebsockets);
|
||||
}
|
||||
|
||||
if (Object.keys(nextProvider).length === 0) {
|
||||
throw new TomlFileValidationError(
|
||||
'Model provider patch must include at least one saved field.'
|
||||
);
|
||||
}
|
||||
providers[providerName] = nextProvider;
|
||||
}
|
||||
|
||||
function applyMcpServerPatch(
|
||||
target: Record<string, unknown>,
|
||||
input: Extract<CodexConfigPatchInput, { kind: 'mcp-server' }>
|
||||
) {
|
||||
if (!isNonEmptyString(input.name)) {
|
||||
throw new TomlFileValidationError('MCP server name is required.');
|
||||
}
|
||||
|
||||
const serverName = input.name.trim();
|
||||
const servers = ensureObject(target, 'mcp_servers');
|
||||
|
||||
if (!['upsert', 'delete'].includes(input.action)) {
|
||||
throw new TomlFileValidationError('Unsupported MCP server action.');
|
||||
}
|
||||
|
||||
if (input.action === 'delete') {
|
||||
delete servers[serverName];
|
||||
deleteIfEmpty(target, 'mcp_servers');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = input.values;
|
||||
if (!values) {
|
||||
throw new TomlFileValidationError('MCP server values are required.');
|
||||
}
|
||||
if (values.transport !== 'stdio' && values.transport !== 'streamable-http') {
|
||||
throw new TomlFileValidationError('MCP transport must be "stdio" or "streamable-http".');
|
||||
}
|
||||
|
||||
const nextServer = { ...(asObject(servers[serverName]) ?? {}) };
|
||||
if (values.transport === 'stdio') {
|
||||
if (!isNonEmptyString(values.command)) {
|
||||
throw new TomlFileValidationError('Stdio MCP servers require a command.');
|
||||
}
|
||||
nextServer.command = values.command.trim();
|
||||
const nextArgs = normalizeStringArray(values.args, 'args');
|
||||
if (nextArgs === null) {
|
||||
delete nextServer.args;
|
||||
} else {
|
||||
nextServer.args = nextArgs;
|
||||
}
|
||||
delete nextServer.url;
|
||||
} else {
|
||||
if (!isNonEmptyString(values.url)) {
|
||||
throw new TomlFileValidationError('HTTP MCP servers require a URL.');
|
||||
}
|
||||
nextServer.url = values.url.trim();
|
||||
delete nextServer.command;
|
||||
delete nextServer.args;
|
||||
}
|
||||
|
||||
if (hasOwn(values, 'enabled')) setBooleanField(nextServer, 'enabled', values.enabled);
|
||||
if (hasOwn(values, 'required')) setBooleanField(nextServer, 'required', values.required);
|
||||
if (hasOwn(values, 'startupTimeoutSec')) {
|
||||
setNumberField(nextServer, 'startup_timeout_sec', values.startupTimeoutSec, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
}
|
||||
if (hasOwn(values, 'toolTimeoutSec')) {
|
||||
setNumberField(nextServer, 'tool_timeout_sec', values.toolTimeoutSec, {
|
||||
integer: true,
|
||||
min: 1,
|
||||
});
|
||||
}
|
||||
if (hasOwn(values, 'enabledTools')) {
|
||||
const nextEnabledTools = normalizeStringArray(values.enabledTools, 'enabledTools');
|
||||
if (nextEnabledTools === null) {
|
||||
delete nextServer.enabled_tools;
|
||||
} else {
|
||||
nextServer.enabled_tools = nextEnabledTools;
|
||||
}
|
||||
}
|
||||
if (hasOwn(values, 'disabledTools')) {
|
||||
const nextDisabledTools = normalizeStringArray(values.disabledTools, 'disabledTools');
|
||||
if (nextDisabledTools === null) {
|
||||
delete nextServer.disabled_tools;
|
||||
} else {
|
||||
nextServer.disabled_tools = nextDisabledTools;
|
||||
}
|
||||
}
|
||||
|
||||
servers[serverName] = nextServer;
|
||||
}
|
||||
|
||||
function parseTransport(server: Record<string, unknown>): CodexMcpServerDiagnostics['transport'] {
|
||||
if (asString(server.command)) return 'stdio';
|
||||
if (asString(server.url)) return 'streamable-http';
|
||||
@@ -294,13 +722,17 @@ export async function getCodexDashboardDiagnostics(): Promise<CodexDashboardDiag
|
||||
supportsConfigOverrides: codexBinarySupportsConfigOverrides(binaryInfo),
|
||||
},
|
||||
file: fileProbe.diagnostics,
|
||||
workspacePath: process.cwd(),
|
||||
config: {
|
||||
model: asString(config?.model),
|
||||
modelReasoningEffort: asString(config?.model_reasoning_effort),
|
||||
modelProvider: asString(config?.model_provider),
|
||||
activeProfile,
|
||||
approvalPolicy: asString(config?.approval_policy),
|
||||
sandboxMode: asString(config?.sandbox_mode),
|
||||
webSearch: asString(config?.web_search),
|
||||
toolOutputTokenLimit: asNumber(config?.tool_output_token_limit),
|
||||
personality: asString(config?.personality),
|
||||
topLevelKeys,
|
||||
profileCount: profileNames.length,
|
||||
profileNames,
|
||||
@@ -357,3 +789,57 @@ export async function saveCodexRawConfig(
|
||||
|
||||
return { success: true, mtime: saved.mtime };
|
||||
}
|
||||
|
||||
export async function patchCodexConfig(
|
||||
input: CodexConfigPatchInput
|
||||
): Promise<CodexConfigPatchResult> {
|
||||
const paths = resolveCodexConfigPaths();
|
||||
const fileProbe = await probeTomlObjectFile(
|
||||
paths.configPath,
|
||||
'Codex user config',
|
||||
paths.configDisplayPath
|
||||
);
|
||||
const nextConfig = { ...assertPatchableToml(fileProbe) };
|
||||
|
||||
switch (input.kind) {
|
||||
case 'top-level':
|
||||
applyTopLevelSettingsPatch(nextConfig, input.values);
|
||||
break;
|
||||
case 'project-trust':
|
||||
applyProjectTrustPatch(nextConfig, input);
|
||||
break;
|
||||
case 'feature':
|
||||
applyFeaturePatch(nextConfig, input);
|
||||
break;
|
||||
case 'profile':
|
||||
applyProfilePatch(nextConfig, input);
|
||||
break;
|
||||
case 'model-provider':
|
||||
applyModelProviderPatch(nextConfig, input);
|
||||
break;
|
||||
case 'mcp-server':
|
||||
applyMcpServerPatch(nextConfig, input);
|
||||
break;
|
||||
default:
|
||||
throw new TomlFileValidationError('Unsupported Codex config patch.');
|
||||
}
|
||||
|
||||
const rawText = stringifyTomlObject(nextConfig);
|
||||
const saved = await writeTomlFileAtomic({
|
||||
filePath: paths.configPath,
|
||||
rawText,
|
||||
expectedMtime: input.expectedMtime,
|
||||
fileLabel: 'config.toml',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: paths.configDisplayPath,
|
||||
resolvedPath: paths.configPath,
|
||||
exists: true,
|
||||
mtime: saved.mtime,
|
||||
rawText,
|
||||
config: nextConfig,
|
||||
parseError: null,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parse } from 'smol-toml';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
|
||||
export interface TomlFileDiagnostics {
|
||||
label: string;
|
||||
@@ -92,6 +92,15 @@ export function parseTomlObjectText(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function stringifyTomlObject(config: Record<string, unknown>): string {
|
||||
if (!isObject(config)) {
|
||||
throw new TomlFileValidationError('config TOML root must be a table.');
|
||||
}
|
||||
|
||||
const text = stringify(config).trimEnd();
|
||||
return text ? `${text}\n` : '';
|
||||
}
|
||||
|
||||
export async function probeTomlObjectFile(
|
||||
filePath: string,
|
||||
label: string,
|
||||
|
||||
@@ -146,11 +146,14 @@ export interface CodexSupportMatrixEntry {
|
||||
|
||||
export interface CodexUserConfigDiagnostics {
|
||||
model: string | null;
|
||||
modelReasoningEffort: string | null;
|
||||
modelProvider: string | null;
|
||||
activeProfile: string | null;
|
||||
approvalPolicy: string | null;
|
||||
sandboxMode: string | null;
|
||||
webSearch: string | null;
|
||||
toolOutputTokenLimit: number | null;
|
||||
personality: string | null;
|
||||
topLevelKeys: string[];
|
||||
profileCount: number;
|
||||
profileNames: string[];
|
||||
@@ -169,6 +172,7 @@ export interface CodexUserConfigDiagnostics {
|
||||
export interface CodexDashboardDiagnostics {
|
||||
binary: CodexBinaryDiagnostics;
|
||||
file: CodexConfigFileDiagnostics;
|
||||
workspacePath: string;
|
||||
config: CodexUserConfigDiagnostics;
|
||||
supportMatrix: CodexSupportMatrixEntry[];
|
||||
warnings: string[];
|
||||
@@ -184,3 +188,83 @@ export interface CodexRawConfigResponse {
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
export interface CodexTopLevelSettingsPatch {
|
||||
model?: string | null;
|
||||
modelReasoningEffort?: string | null;
|
||||
modelProvider?: string | null;
|
||||
approvalPolicy?: string | null;
|
||||
sandboxMode?: string | null;
|
||||
webSearch?: string | null;
|
||||
toolOutputTokenLimit?: number | null;
|
||||
personality?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexProfilePatchValues extends CodexTopLevelSettingsPatch {}
|
||||
|
||||
export interface CodexModelProviderPatchValues {
|
||||
displayName?: string | null;
|
||||
baseUrl?: string | null;
|
||||
envKey?: string | null;
|
||||
wireApi?: string | null;
|
||||
requiresOpenaiAuth?: boolean | null;
|
||||
supportsWebsockets?: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexMcpServerPatchValues {
|
||||
transport: 'stdio' | 'streamable-http';
|
||||
command?: string | null;
|
||||
args?: string[] | null;
|
||||
url?: string | null;
|
||||
enabled?: boolean | null;
|
||||
required?: boolean | null;
|
||||
startupTimeoutSec?: number | null;
|
||||
toolTimeoutSec?: number | null;
|
||||
enabledTools?: string[] | null;
|
||||
disabledTools?: string[] | null;
|
||||
}
|
||||
|
||||
export type CodexConfigPatchInput =
|
||||
| {
|
||||
kind: 'top-level';
|
||||
expectedMtime?: number;
|
||||
values: CodexTopLevelSettingsPatch;
|
||||
}
|
||||
| {
|
||||
kind: 'project-trust';
|
||||
expectedMtime?: number;
|
||||
path: string;
|
||||
trustLevel: string | null;
|
||||
}
|
||||
| {
|
||||
kind: 'feature';
|
||||
expectedMtime?: number;
|
||||
feature: string;
|
||||
enabled: boolean | null;
|
||||
}
|
||||
| {
|
||||
kind: 'profile';
|
||||
expectedMtime?: number;
|
||||
action: 'set-active' | 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexProfilePatchValues;
|
||||
setAsActive?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'model-provider';
|
||||
expectedMtime?: number;
|
||||
action: 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexModelProviderPatchValues;
|
||||
}
|
||||
| {
|
||||
kind: 'mcp-server';
|
||||
expectedMtime?: number;
|
||||
action: 'upsert' | 'delete';
|
||||
name: string;
|
||||
values?: CodexMcpServerPatchValues;
|
||||
};
|
||||
|
||||
export interface CodexConfigPatchResult extends CodexRawConfigResponse {
|
||||
success: true;
|
||||
}
|
||||
Reference in new issue
Block a user