mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 16:17:02 +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
@@ -7,6 +7,7 @@ import {
|
||||
CodexRawConfigValidationError,
|
||||
getCodexDashboardDiagnostics,
|
||||
getCodexRawConfig,
|
||||
patchCodexConfig,
|
||||
resolveCodexConfigPaths,
|
||||
saveCodexRawConfig,
|
||||
summarizeCodexFeatureFlags,
|
||||
@@ -262,4 +263,172 @@ bearer_token = "secret"
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigConflictError);
|
||||
});
|
||||
|
||||
it('patches top-level settings and project trust through structured controls', async () => {
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'top-level',
|
||||
values: {
|
||||
model: 'gpt-5.4',
|
||||
modelReasoningEffort: 'high',
|
||||
approvalPolicy: 'never',
|
||||
sandboxMode: 'workspace-write',
|
||||
webSearch: 'cached',
|
||||
toolOutputTokenLimit: 12000,
|
||||
personality: 'pragmatic',
|
||||
},
|
||||
});
|
||||
|
||||
await patchCodexConfig({
|
||||
kind: 'project-trust',
|
||||
path: '/tmp/workspace-a',
|
||||
trustLevel: 'trusted',
|
||||
expectedMtime: result.mtime,
|
||||
});
|
||||
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
expect(diagnostics.config.model).toBe('gpt-5.4');
|
||||
expect(diagnostics.config.modelReasoningEffort).toBe('high');
|
||||
expect(diagnostics.config.toolOutputTokenLimit).toBe(12000);
|
||||
expect(diagnostics.config.personality).toBe('pragmatic');
|
||||
expect(diagnostics.config.projectTrust[0]?.path).toBe('/tmp/workspace-a');
|
||||
expect(result.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(result.config?.model).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('expands home paths for project trust and rejects relative paths', async () => {
|
||||
const homeWorkspacePath = path.join(os.homedir(), 'codex-workspace');
|
||||
const expanded = await patchCodexConfig({
|
||||
kind: 'project-trust',
|
||||
path: '~/codex-workspace',
|
||||
trustLevel: 'trusted',
|
||||
});
|
||||
|
||||
expect(expanded.rawText).toContain(`[projects."${homeWorkspacePath}"]`);
|
||||
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'project-trust',
|
||||
path: './relative-workspace',
|
||||
trustLevel: 'trusted',
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
});
|
||||
|
||||
it('patches profiles, providers, and mcp servers through structured controls', async () => {
|
||||
const providerResult = await patchCodexConfig({
|
||||
kind: 'model-provider',
|
||||
action: 'upsert',
|
||||
name: 'cliproxy',
|
||||
values: {
|
||||
displayName: 'CLIProxy',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
envKey: 'CLIPROXY_API_KEY',
|
||||
wireApi: 'responses',
|
||||
},
|
||||
});
|
||||
|
||||
const profileResult = await patchCodexConfig({
|
||||
kind: 'profile',
|
||||
action: 'upsert',
|
||||
name: 'deep-review',
|
||||
values: {
|
||||
model: 'gpt-5.4',
|
||||
modelProvider: 'cliproxy',
|
||||
modelReasoningEffort: 'xhigh',
|
||||
},
|
||||
setAsActive: true,
|
||||
expectedMtime: providerResult.mtime,
|
||||
});
|
||||
|
||||
await patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
action: 'upsert',
|
||||
name: 'playwright',
|
||||
values: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest'],
|
||||
enabled: true,
|
||||
required: false,
|
||||
startupTimeoutSec: 15,
|
||||
toolTimeoutSec: 30,
|
||||
},
|
||||
expectedMtime: profileResult.mtime,
|
||||
});
|
||||
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
expect(diagnostics.config.activeProfile).toBe('deep-review');
|
||||
expect(diagnostics.config.modelProviderCount).toBe(1);
|
||||
expect(diagnostics.config.mcpServerCount).toBe(1);
|
||||
|
||||
const raw = await getCodexRawConfig();
|
||||
expect(raw.rawText).toContain('[profiles.deep-review]');
|
||||
expect(raw.rawText).toContain('[model_providers.cliproxy]');
|
||||
expect(raw.rawText).toContain('[mcp_servers.playwright]');
|
||||
expect(profileResult.rawText).toContain('[profiles.deep-review]');
|
||||
expect(profileResult.config?.profile).toBe('deep-review');
|
||||
});
|
||||
|
||||
it('rejects structured patches when config.toml is invalid', async () => {
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
|
||||
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
});
|
||||
|
||||
it('removes feature overrides when a feature is reset to inherited state', async () => {
|
||||
const enabled = await patchCodexConfig({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(enabled.rawText).toContain('[features]');
|
||||
expect(enabled.rawText).toContain('multi_agent = true');
|
||||
|
||||
const reset = await patchCodexConfig({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: null,
|
||||
expectedMtime: enabled.mtime,
|
||||
});
|
||||
|
||||
expect(reset.rawText).not.toContain('[features]');
|
||||
expect(reset.config?.features).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects malformed structured patch payloads at runtime', async () => {
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: 'true' as unknown as boolean | null,
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'project-trust',
|
||||
path: '~/codex-workspace',
|
||||
trustLevel: 'always',
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
action: 'upsert',
|
||||
name: 'remote',
|
||||
values: {
|
||||
transport: 'http' as 'stdio' | 'streamable-http',
|
||||
url: 'https://example.test/mcp',
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempDir = '';
|
||||
let codexHome = '';
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-routes-test-'));
|
||||
codexHome = path.join(tempDir, '.codex-home');
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
|
||||
const codexRoutesModule = await import('../../../src/web-server/routes/codex-routes');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/codex', codexRoutesModule.default);
|
||||
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
await new Promise<void>((resolve) => server.on('listening', () => resolve()));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
if (originalCodexHome !== undefined) {
|
||||
process.env.CODEX_HOME = originalCodexHome;
|
||||
} else {
|
||||
delete process.env.CODEX_HOME;
|
||||
}
|
||||
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('codex routes', () => {
|
||||
it('returns the current raw config snapshot from PATCH /config/patch', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'top-level',
|
||||
values: {
|
||||
model: 'gpt-5.4',
|
||||
sandboxMode: 'workspace-write',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
mtime: number;
|
||||
rawText: string;
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
};
|
||||
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.exists).toBe(true);
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
expect(json.parseError).toBeNull();
|
||||
expect(json.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(json.rawText).toContain('sandbox_mode = "workspace-write"');
|
||||
expect(json.config?.model).toBe('gpt-5.4');
|
||||
|
||||
const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(written).toBe(json.rawText);
|
||||
});
|
||||
|
||||
it('returns 409 when PATCH /config/patch receives a stale expectedMtime', async () => {
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
expectedMtime: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const json = (await res.json()) as { error: string; mtime: number };
|
||||
expect(json.error).toContain('File modified externally.');
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns 400 when PATCH /config/patch omits kind', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toBe('kind is required.');
|
||||
});
|
||||
|
||||
it('returns 400 when PATCH /config/patch receives an invalid trust path', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'project-trust',
|
||||
path: './relative-workspace',
|
||||
trustLevel: 'trusted',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toContain('Project path must be absolute');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user