mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 18:18:21 +00:00
Merge pull request #850 from kaitranntt/kai/feat/issue-773-codex-runtime-target
feat: add codex runtime target and dashboard parity
This commit is contained in:
85 files changed
+7867
-208
No files matched your search
@@ -131,6 +131,8 @@ describe('cross-platform', () => {
|
||||
assert(packageJson.bin.ccs, 'bin field should specify ccs command');
|
||||
assert(packageJson.bin['ccs-droid'], 'bin field should specify ccs-droid command');
|
||||
assert(packageJson.bin.ccsd, 'bin field should specify ccsd command');
|
||||
assert(packageJson.bin['ccs-codex'], 'bin field should specify ccs-codex command');
|
||||
assert(packageJson.bin.ccsx, 'bin field should specify ccsx command');
|
||||
assert.notStrictEqual(
|
||||
packageJson.bin['ccs-droid'],
|
||||
packageJson.bin.ccs,
|
||||
@@ -141,10 +143,24 @@ describe('cross-platform', () => {
|
||||
packageJson.bin.ccsd,
|
||||
'legacy ccsd alias should share the dedicated droid runtime entrypoint'
|
||||
);
|
||||
assert.notStrictEqual(
|
||||
packageJson.bin['ccs-codex'],
|
||||
packageJson.bin.ccs,
|
||||
'ccs-codex should use a dedicated runtime entrypoint'
|
||||
);
|
||||
assert.strictEqual(
|
||||
packageJson.bin['ccs-codex'],
|
||||
packageJson.bin.ccsx,
|
||||
'ccsx should share the dedicated codex runtime entrypoint'
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])),
|
||||
'dedicated droid runtime entrypoint should exist'
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-codex'])),
|
||||
'dedicated codex runtime entrypoint should exist'
|
||||
);
|
||||
assert(packageJson.scripts, 'package.json should have scripts field');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { listApiProfiles } from '../../../src/api/services/profile-reader';
|
||||
import { runWithScopedConfigDir, setGlobalConfigDir } from '../../../src/utils/config-manager';
|
||||
|
||||
describe('profile reader target sanitization', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalUnifiedMode: string | undefined;
|
||||
|
||||
function getScopedCcsDir(): string {
|
||||
return path.join(tempHome, '.ccs');
|
||||
}
|
||||
|
||||
async function runInScopedCcsDir<T>(fn: () => T): Promise<T> {
|
||||
return await runWithScopedConfigDir(getScopedCcsDir(), fn);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-reader-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_DIR;
|
||||
delete process.env.CCS_UNIFIED_CONFIG;
|
||||
setGlobalConfigDir(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (originalCcsDir === undefined) {
|
||||
delete process.env.CCS_DIR;
|
||||
} else {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
}
|
||||
|
||||
if (originalUnifiedMode === undefined) {
|
||||
delete process.env.CCS_UNIFIED_CONFIG;
|
||||
} else {
|
||||
process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
|
||||
}
|
||||
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes legacy stored codex targets back to claude for profiles and variants', async () => {
|
||||
const ccsDir = getScopedCcsDir();
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profiles: { demo: '~/.ccs/demo.settings.json' },
|
||||
profile_targets: { demo: 'codex' },
|
||||
cliproxy: {
|
||||
routed: {
|
||||
provider: 'codex',
|
||||
settings: '~/.ccs/routed.settings.json',
|
||||
target: 'codex',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'demo.settings.json'),
|
||||
JSON.stringify(
|
||||
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const result = await runInScopedCcsDir(() => listApiProfiles());
|
||||
|
||||
expect(result.profiles).toHaveLength(1);
|
||||
expect(result.profiles[0]?.target).toBe('claude');
|
||||
expect(result.variants).toHaveLength(1);
|
||||
expect(result.variants[0]?.target).toBe('claude');
|
||||
});
|
||||
|
||||
it('normalizes unified stored codex targets back to claude for profiles and variants', async () => {
|
||||
const ccsDir = getScopedCcsDir();
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'profiles:',
|
||||
' demo:',
|
||||
' type: api',
|
||||
' settings: ~/.ccs/demo.settings.json',
|
||||
' target: codex',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: []',
|
||||
' variants:',
|
||||
' routed:',
|
||||
' provider: codex',
|
||||
' settings: ~/.ccs/routed.settings.json',
|
||||
' target: codex',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'demo.settings.json'),
|
||||
JSON.stringify(
|
||||
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const result = await runInScopedCcsDir(() => listApiProfiles());
|
||||
|
||||
expect(result.profiles).toHaveLength(1);
|
||||
expect(result.profiles[0]?.target).toBe('claude');
|
||||
expect(result.variants).toHaveLength(1);
|
||||
expect(result.variants[0]?.target).toBe('claude');
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,13 @@ describe('api-command arg parser', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects runtime-only codex as a persisted API target value', () => {
|
||||
const parsed = parseApiCommandArgs(['my-api', '--target', 'codex']);
|
||||
|
||||
expect(parsed.target).toBeUndefined();
|
||||
expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']);
|
||||
});
|
||||
|
||||
test('collects missing-value error for --target with no value', () => {
|
||||
const parsed = parseApiCommandArgs(['my-api', '--target']);
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ describe('cliproxy variant arg parser', () => {
|
||||
expect(parsed.errors).toEqual(['Missing value for --target']);
|
||||
});
|
||||
|
||||
test('rejects runtime-only codex as a persisted variant target value', () => {
|
||||
const parsed = parseProfileArgs(['variant-a', '--target', 'codex']);
|
||||
|
||||
expect(parsed.target).toBeUndefined();
|
||||
expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']);
|
||||
});
|
||||
|
||||
test('uses last --target value when repeated', () => {
|
||||
const parsed = parseProfileArgs(['variant-a', '--target', 'claude', '--target=droid']);
|
||||
|
||||
|
||||
@@ -99,6 +99,18 @@ describe('help command parity', () => {
|
||||
expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true);
|
||||
});
|
||||
|
||||
test('root help documents native Codex runtime alias and runtime-only scope', async () => {
|
||||
const lines: string[] = [];
|
||||
await handleHelpCommand((line) => lines.push(line));
|
||||
|
||||
const rendered = stripAnsi(lines.join('\n'));
|
||||
expect(rendered.includes('ccs-codex <profile> [args]')).toBe(true);
|
||||
expect(rendered.includes('ccsx <profile> [args]')).toBe(true);
|
||||
expect(rendered.includes('ccs --target codex')).toBe(true);
|
||||
expect(rendered.includes('ccs codex-api --target codex')).toBe(true);
|
||||
expect(rendered.includes('codex (runtime-only)')).toBe(true);
|
||||
});
|
||||
|
||||
test('api help documents create-time Claude [1m] flags and entitlement warning', async () => {
|
||||
const lines: string[] = [];
|
||||
await showApiCommandHelp((line) => lines.push(line));
|
||||
@@ -113,4 +125,14 @@ describe('help command parity', () => {
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('api help documents Codex bridge runtime launch separately from persisted targets', async () => {
|
||||
const lines: string[] = [];
|
||||
await showApiCommandHelp((line) => lines.push(line));
|
||||
|
||||
const rendered = stripAnsi(lines.join('\n'));
|
||||
expect(rendered.includes('ccs api create codex-api --cliproxy-provider codex')).toBe(true);
|
||||
expect(rendered.includes('ccs codex-api --target codex')).toBe(true);
|
||||
expect(rendered.includes('Default target: claude or droid (create)')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -71,13 +71,35 @@ describe('ccsd alias integration', () => {
|
||||
expect(path.basename(argvPath)).toBe('ccs-droid');
|
||||
});
|
||||
|
||||
it('should preserve ccs-codex symlink basename in argv[1] under node', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const argvPath = probeArgvPath('ccs-codex');
|
||||
expect(path.basename(argvPath)).toBe('ccs-codex');
|
||||
});
|
||||
|
||||
it('should preserve ccsx symlink basename in argv[1] under node', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const argvPath = probeArgvPath('ccsx');
|
||||
expect(path.basename(argvPath)).toBe('ccsx');
|
||||
});
|
||||
|
||||
it('should preserve extension-style alias basenames for wrapper compatibility', () => {
|
||||
const cmdArgvPath = probeArgvPathDirect('ccsd.cmd');
|
||||
const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1');
|
||||
const explicitCmdArgvPath = probeArgvPathDirect('ccs-droid.cmd');
|
||||
const codexCmdArgvPath = probeArgvPathDirect('ccs-codex.cmd');
|
||||
const codexShortCmdArgvPath = probeArgvPathDirect('ccsx.cmd');
|
||||
|
||||
expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd');
|
||||
expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1');
|
||||
expect(path.basename(explicitCmdArgvPath)).toBe('ccs-droid.cmd');
|
||||
expect(path.basename(codexCmdArgvPath)).toBe('ccs-codex.cmd');
|
||||
expect(path.basename(codexShortCmdArgvPath)).toBe('ccsx.cmd');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CodexAdapter } from '../../../src/targets/codex-adapter';
|
||||
|
||||
describe('CodexAdapter', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
test('supports only adapter-level default and cliproxy profile types', () => {
|
||||
expect(adapter.supportsProfileType('default')).toBe(true);
|
||||
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
|
||||
expect(adapter.supportsProfileType('settings')).toBe(false);
|
||||
expect(adapter.supportsProfileType('account')).toBe(false);
|
||||
expect(adapter.supportsProfileType('copilot')).toBe(false);
|
||||
});
|
||||
|
||||
test('passes default-mode args through unchanged', () => {
|
||||
expect(
|
||||
adapter.buildArgs('default', ['--search'], {
|
||||
profileType: 'default',
|
||||
})
|
||||
).toEqual(['--search']);
|
||||
});
|
||||
|
||||
test('translates default-mode reasoning overrides into transient codex config', () => {
|
||||
const args = adapter.buildArgs('default', ['--search'], {
|
||||
profileType: 'default',
|
||||
creds: {
|
||||
profile: 'default',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
reasoningOverride: 'medium',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']);
|
||||
});
|
||||
|
||||
test('rejects default-mode reasoning overrides when codex lacks config override support', () => {
|
||||
expect(() =>
|
||||
adapter.buildArgs('default', ['--search'], {
|
||||
profileType: 'default',
|
||||
creds: {
|
||||
profile: 'default',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
reasoningOverride: 'high',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.1.0',
|
||||
features: [],
|
||||
},
|
||||
})
|
||||
).toThrow(/does not advertise --config overrides/);
|
||||
});
|
||||
|
||||
test('injects transient config overrides for CCS-backed launches', () => {
|
||||
const args = adapter.buildArgs('codex', ['--search'], {
|
||||
profileType: 'cliproxy',
|
||||
creds: {
|
||||
profile: 'codex',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
apiKey: 'cliproxy-token',
|
||||
model: 'gpt-5.4',
|
||||
reasoningOverride: 'high',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(args).toContain('-c');
|
||||
expect(args).toContain('model_provider="ccs_runtime"');
|
||||
expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"');
|
||||
expect(args).toContain('model="gpt-5.4"');
|
||||
expect(args).toContain('model_reasoning_effort="high"');
|
||||
expect(args.at(-1)).toBe('--search');
|
||||
});
|
||||
|
||||
test('fails fast when Codex binary lacks config override support', () => {
|
||||
expect(() =>
|
||||
adapter.buildArgs('codex', [], {
|
||||
profileType: 'cliproxy',
|
||||
creds: {
|
||||
profile: 'codex',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
apiKey: 'cliproxy-token',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.1.0',
|
||||
features: [],
|
||||
},
|
||||
})
|
||||
).toThrow(/does not advertise --config overrides/);
|
||||
});
|
||||
|
||||
test('rejects native Codex provider-selection flags for CCS-backed launches', () => {
|
||||
expect(() =>
|
||||
adapter.buildArgs('codex', ['--profile', 'other', '--search'], {
|
||||
profileType: 'cliproxy',
|
||||
creds: {
|
||||
profile: 'codex',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
apiKey: 'cliproxy-token',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
})
|
||||
).toThrow(/does not allow --profile\/-p/);
|
||||
});
|
||||
|
||||
test('rejects user-supplied --config overrides for CCS-backed launches', () => {
|
||||
const options = {
|
||||
profileType: 'cliproxy' as const,
|
||||
creds: {
|
||||
profile: 'codex',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
apiKey: 'cliproxy-token',
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => adapter.buildArgs('codex', ['-c', 'model="other"', '--search'], options)).toThrow(
|
||||
/does not allow --config\/-c/
|
||||
);
|
||||
expect(() =>
|
||||
adapter.buildArgs('codex', ['--config=model="other"', '--search'], options)
|
||||
).toThrow(/does not allow --config\/-c/);
|
||||
});
|
||||
|
||||
test('rejects unsupported reasoning override values for CCS-backed launches', () => {
|
||||
expect(() =>
|
||||
adapter.buildArgs('codex', ['--search'], {
|
||||
profileType: 'cliproxy',
|
||||
creds: {
|
||||
profile: 'codex',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
apiKey: 'cliproxy-token',
|
||||
reasoningOverride: 8192,
|
||||
},
|
||||
binaryInfo: {
|
||||
path: '/tmp/codex',
|
||||
needsShell: false,
|
||||
features: ['config-overrides'],
|
||||
},
|
||||
})
|
||||
).toThrow(/supports reasoning levels only/);
|
||||
});
|
||||
|
||||
test('injects CCS_CODEX_API_KEY for CCS-backed launches only', () => {
|
||||
const settingsEnv = adapter.buildEnv(
|
||||
{
|
||||
profile: 'codex',
|
||||
baseUrl: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
apiKey: 'cliproxy-token',
|
||||
},
|
||||
'cliproxy'
|
||||
);
|
||||
expect(settingsEnv.CCS_CODEX_API_KEY).toBe('cliproxy-token');
|
||||
|
||||
const defaultEnv = adapter.buildEnv(
|
||||
{
|
||||
profile: 'default',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
},
|
||||
'default'
|
||||
);
|
||||
expect(defaultEnv.CCS_CODEX_API_KEY).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as childProcess from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { detectCodexCli, getCodexBinaryInfo } from '../../../src/targets/codex-detector';
|
||||
|
||||
describe('codex-detector', () => {
|
||||
let tmpDir: string;
|
||||
let originalPath: string | undefined;
|
||||
let originalCodexPath: string | undefined;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-detector-test-'));
|
||||
originalPath = process.env.PATH;
|
||||
originalCodexPath = process.env.CCS_CODEX_PATH;
|
||||
process.env.PATH = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
|
||||
if (originalPath !== undefined) process.env.PATH = originalPath;
|
||||
else delete process.env.PATH;
|
||||
|
||||
if (originalCodexPath !== undefined) process.env.CCS_CODEX_PATH = originalCodexPath;
|
||||
else delete process.env.CCS_CODEX_PATH;
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should prefer CCS_CODEX_PATH when it points to a file', () => {
|
||||
const fakeCodex = path.join(tmpDir, 'codex');
|
||||
fs.writeFileSync(fakeCodex, '#!/bin/sh\necho codex\n');
|
||||
process.env.CCS_CODEX_PATH = fakeCodex;
|
||||
|
||||
expect(detectCodexCli()).toBe(fakeCodex);
|
||||
});
|
||||
|
||||
it('should return null when CCS_CODEX_PATH points to a directory', () => {
|
||||
process.env.CCS_CODEX_PATH = tmpDir;
|
||||
expect(detectCodexCli()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return binary info without throwing when help probing fails', () => {
|
||||
const fakeCodex = path.join(tmpDir, 'codex');
|
||||
fs.writeFileSync(fakeCodex, '');
|
||||
process.env.CCS_CODEX_PATH = fakeCodex;
|
||||
|
||||
expect(() => getCodexBinaryInfo()).not.toThrow();
|
||||
});
|
||||
|
||||
it('probes Windows cmd wrappers through the shell so config override support is detected', () => {
|
||||
const fakeCodex = path.join(tmpDir, 'codex.cmd');
|
||||
fs.writeFileSync(fakeCodex, '');
|
||||
process.env.CCS_CODEX_PATH = fakeCodex;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
|
||||
return String(command).includes('cmd.exe') && Array.isArray(args) && args.join(' ').includes('--help')
|
||||
? 'Codex CLI\n -c, --config <key=value>\n'
|
||||
: 'codex-cli 0.118.0-alpha.3';
|
||||
});
|
||||
|
||||
const info = getCodexBinaryInfo();
|
||||
|
||||
expect(execFileSyncSpy).toHaveBeenCalled();
|
||||
expect(info?.needsShell).toBe(true);
|
||||
expect(info?.features).toContain('config-overrides');
|
||||
|
||||
execFileSyncSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
|
||||
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function readLoggedCodexCalls(logPath: string): string[][] {
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readFileSync(logPath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as string[]);
|
||||
}
|
||||
|
||||
describe('codex runtime integration', () => {
|
||||
let tmpHome: string;
|
||||
let ccsDir: string;
|
||||
let fakeCodexPath: string;
|
||||
let codexArgsLogPath: string;
|
||||
let emptyPathDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-route-it-'));
|
||||
ccsDir = path.join(tmpHome, '.ccs');
|
||||
fakeCodexPath = path.join(tmpHome, 'fake-codex.js');
|
||||
codexArgsLogPath = path.join(tmpHome, 'codex-args.log');
|
||||
emptyPathDir = path.join(tmpHome, 'empty-bin');
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.mkdirSync(emptyPathDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
fakeCodexPath,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const out = process.env.CCS_TEST_CODEX_ARGS_OUT;
|
||||
if (out) {
|
||||
fs.appendFileSync(out, JSON.stringify(process.argv.slice(2)) + '\\n');
|
||||
}
|
||||
if (process.argv[2] === '--version') {
|
||||
process.stdout.write(process.env.CCS_TEST_CODEX_VERSION || 'codex-cli 0.118.0-alpha.3');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv[2] === '--help') {
|
||||
process.stdout.write(
|
||||
process.env.CCS_TEST_CODEX_HELP ||
|
||||
' -c, --config <key=value>\\n -p, --profile <CONFIG_PROFILE>\\n'
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(0);
|
||||
`,
|
||||
{ encoding: 'utf8', mode: 0o755 }
|
||||
);
|
||||
fs.chmodSync(fakeCodexPath, 0o755);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ignores numeric CCS_THINKING env overrides for native Codex default mode', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_THINKING: '8192',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls.at(-1)).toEqual(['fix failing tests']);
|
||||
});
|
||||
|
||||
it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_THINKING: 'off',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls.at(-1)).toEqual(['fix failing tests']);
|
||||
});
|
||||
|
||||
it('fails fast when native Codex reasoning overrides need unsupported --config support', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', '--effort', 'high', 'fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_HELP: ' -p, --profile <CONFIG_PROFILE>\\n',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('does not advertise --config overrides');
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls).toEqual([['--version'], ['--help']]);
|
||||
});
|
||||
|
||||
it('reports unsupported generic settings profiles before Codex install guidance', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'myglm.settings.json');
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://example.invalid/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
profiles: {
|
||||
myglm: settingsPath,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = runCcs(['myglm', '--target', 'codex', 'fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
PATH: emptyPathDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.'
|
||||
);
|
||||
expect(result.stderr).not.toContain('Install a recent @openai/codex build');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
|
||||
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Codex settings bridge launch', () => {
|
||||
let tmpHome = '';
|
||||
let ccsDir = '';
|
||||
let settingsPath = '';
|
||||
let fakeCodexPath = '';
|
||||
let codexArgsLogPath = '';
|
||||
let codexEnvLogPath = '';
|
||||
let baseEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-bridge-launch-'));
|
||||
ccsDir = path.join(tmpHome, '.ccs');
|
||||
settingsPath = path.join(ccsDir, 'codex-api.settings.json');
|
||||
fakeCodexPath = path.join(tmpHome, 'fake-codex.sh');
|
||||
codexArgsLogPath = path.join(tmpHome, 'codex-args.txt');
|
||||
codexEnvLogPath = path.join(tmpHome, 'codex-env.txt');
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { 'codex-api': settingsPath } }, null, 2) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'bridge-token',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
fakeCodexPath,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then
|
||||
echo "codex-cli 0.118.0-alpha.3"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "--help" ]; then
|
||||
cat <<'EOF'
|
||||
Codex CLI
|
||||
-c, --config <key=value>
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf "%s\\n" "$@" > "${codexArgsLogPath}"
|
||||
printf "%s" "$CCS_CODEX_API_KEY" > "${codexEnvLogPath}"
|
||||
exit 0
|
||||
`,
|
||||
{ encoding: 'utf8', mode: 0o755 }
|
||||
);
|
||||
fs.chmodSync(fakeCodexPath, 0o755);
|
||||
|
||||
baseEnv = {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_DEBUG: '1',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('launches Codex bridge settings profiles and injects runtime overrides', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['codex-api', '--target', 'codex', '--effort', 'high', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain('does not support this profile');
|
||||
|
||||
const argsLog = fs.readFileSync(codexArgsLogPath, 'utf8');
|
||||
expect(argsLog).toContain('model_provider="ccs_runtime"');
|
||||
expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"');
|
||||
expect(argsLog).toContain('model_reasoning_effort="high"');
|
||||
expect(argsLog).toContain('smoke');
|
||||
expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token');
|
||||
});
|
||||
|
||||
it('rejects native Codex profile flags when CCS manages the bridge runtime', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('does not allow --profile/-p');
|
||||
expect(fs.existsSync(codexArgsLogPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getRegisteredTargets,
|
||||
ClaudeAdapter,
|
||||
DroidAdapter,
|
||||
CodexAdapter,
|
||||
} from '../../../src/targets';
|
||||
|
||||
describe('target-registry', () => {
|
||||
@@ -20,6 +21,7 @@ describe('target-registry', () => {
|
||||
// Re-register adapters (registry is module-scoped singleton)
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
registerTarget(new CodexAdapter());
|
||||
});
|
||||
|
||||
it('should register and retrieve claude adapter', () => {
|
||||
@@ -39,6 +41,12 @@ describe('target-registry', () => {
|
||||
expect(adapter.type).toBe('claude');
|
||||
});
|
||||
|
||||
it('should register and retrieve codex adapter', () => {
|
||||
const adapter = getTarget('codex');
|
||||
expect(adapter.type).toBe('codex');
|
||||
expect(adapter.displayName).toBe('Codex CLI');
|
||||
});
|
||||
|
||||
it('should throw for unknown target', () => {
|
||||
expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/);
|
||||
});
|
||||
@@ -46,6 +54,7 @@ describe('target-registry', () => {
|
||||
it('should check target existence', () => {
|
||||
expect(hasTarget('claude')).toBe(true);
|
||||
expect(hasTarget('droid')).toBe(true);
|
||||
expect(hasTarget('codex')).toBe(true);
|
||||
expect(hasTarget('unknown' as never)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -53,6 +62,7 @@ describe('target-registry', () => {
|
||||
const targets = getRegisteredTargets();
|
||||
expect(targets).toContain('claude');
|
||||
expect(targets).toContain('droid');
|
||||
expect(targets).toContain('codex');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target-
|
||||
describe('resolveTargetType', () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalDroidAliases = process.env.CCS_DROID_ALIASES;
|
||||
const originalCodexAliases = process.env.CCS_CODEX_ALIASES;
|
||||
const originalTargetAliases = process.env.CCS_TARGET_ALIASES;
|
||||
const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
|
||||
|
||||
@@ -18,6 +19,12 @@ describe('resolveTargetType', () => {
|
||||
process.env.CCS_DROID_ALIASES = originalDroidAliases;
|
||||
}
|
||||
|
||||
if (originalCodexAliases === undefined) {
|
||||
delete process.env.CCS_CODEX_ALIASES;
|
||||
} else {
|
||||
process.env.CCS_CODEX_ALIASES = originalCodexAliases;
|
||||
}
|
||||
|
||||
if (originalTargetAliases === undefined) {
|
||||
delete process.env.CCS_TARGET_ALIASES;
|
||||
} else {
|
||||
@@ -41,6 +48,11 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType(['--target', 'droid'])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect --target codex', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'codex'])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should detect --target claude', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||
@@ -56,6 +68,11 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude');
|
||||
});
|
||||
|
||||
it('should ignore runtime-only codex target when it appears in persisted profile config', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([], { target: 'codex' })).toBe('claude');
|
||||
});
|
||||
|
||||
it('should prioritize --target flag over profile config', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude');
|
||||
@@ -71,15 +88,31 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect built-in ccs-codex argv[0] alias', () => {
|
||||
process.argv = ['node', 'ccs-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should detect built-in ccsx argv[0] alias', () => {
|
||||
process.argv = ['node', 'ccsx'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should detect custom target aliases from CCS_TARGET_ALIASES', () => {
|
||||
process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid';
|
||||
process.argv = ['node', 'my-droid'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect codex aliases from CCS_TARGET_ALIASES', () => {
|
||||
process.env.CCS_TARGET_ALIASES = 'codex=codexx,team-codex';
|
||||
process.argv = ['node', 'team-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should ignore unsupported targets in CCS_TARGET_ALIASES', () => {
|
||||
process.env.CCS_TARGET_ALIASES = 'codex=ccsx;droid=ccs-droid-custom';
|
||||
process.argv = ['node', 'ccsx'];
|
||||
process.env.CCS_TARGET_ALIASES = 'not-a-target=mystery-codex;droid=ccs-droid-custom';
|
||||
process.argv = ['node', 'mystery-codex'];
|
||||
expect(resolveTargetType([])).toBe('claude');
|
||||
});
|
||||
|
||||
@@ -89,6 +122,12 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect custom argv[0] aliases from CCS_CODEX_ALIASES', () => {
|
||||
process.env.CCS_CODEX_ALIASES = 'codexx,my-codex';
|
||||
process.argv = ['node', 'my-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should merge CCS_TARGET_ALIASES and CCS_DROID_ALIASES', () => {
|
||||
process.env.CCS_TARGET_ALIASES = 'droid=team-droid';
|
||||
process.env.CCS_DROID_ALIASES = 'legacy-droid';
|
||||
@@ -100,6 +139,17 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should merge CCS_TARGET_ALIASES and CCS_CODEX_ALIASES', () => {
|
||||
process.env.CCS_TARGET_ALIASES = 'codex=team-codex';
|
||||
process.env.CCS_CODEX_ALIASES = 'legacy-codex';
|
||||
|
||||
process.argv = ['node', 'team-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
|
||||
process.argv = ['node', 'legacy-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should ignore invalid custom alias entries', () => {
|
||||
process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,';
|
||||
process.argv = ['node', '../bad'];
|
||||
@@ -112,6 +162,12 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect internal entry target for codex runtime bins', () => {
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should normalize argv[0] and custom aliases case-insensitively', () => {
|
||||
process.env.CCS_DROID_ALIASES = 'DroidCaps';
|
||||
process.argv = ['node', 'DROIDCAPS'];
|
||||
@@ -128,11 +184,21 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .cmd extension on built-in codex alias', () => {
|
||||
process.argv = ['node', 'ccs-codex.cmd'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should strip .bat extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.bat'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .bat extension on codex shortcut alias', () => {
|
||||
process.argv = ['node', 'ccsx.bat'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should strip .ps1 extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.ps1'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
@@ -153,6 +219,11 @@ describe('resolveTargetType', () => {
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should handle full path argv[0] for ccs-codex', () => {
|
||||
process.argv = ['node', '/usr/local/bin/ccs-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should prioritize --target over argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||
@@ -176,8 +247,10 @@ describe('resolveTargetType', () => {
|
||||
});
|
||||
|
||||
it('should keep reserved command names authoritative', () => {
|
||||
process.env.CCS_TARGET_ALIASES = 'claude=ccs,ccs-droid,ccsd;droid=mydroid';
|
||||
process.env.CCS_TARGET_ALIASES =
|
||||
'claude=ccs,ccs-droid,ccsd,ccs-codex,ccsx;droid=mydroid;codex=mycodex';
|
||||
process.env.CCS_DROID_ALIASES = 'ccs,ccs-droid,ccsd,legacy-droid';
|
||||
process.env.CCS_CODEX_ALIASES = 'ccs,ccs-codex,ccsx,legacy-codex';
|
||||
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([])).toBe('claude');
|
||||
@@ -188,11 +261,23 @@ describe('resolveTargetType', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
|
||||
process.argv = ['node', 'ccs-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
|
||||
process.argv = ['node', 'ccsx'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
|
||||
process.argv = ['node', 'mydroid'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
|
||||
process.argv = ['node', 'legacy-droid'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
|
||||
process.argv = ['node', 'mycodex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
|
||||
process.argv = ['node', 'legacy-codex'];
|
||||
expect(resolveTargetType([])).toBe('codex');
|
||||
});
|
||||
|
||||
it('should throw for invalid --target value', () => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { evaluateTargetRuntimeCompatibility } from '../../../src/targets/target-runtime-compatibility';
|
||||
|
||||
describe('evaluateTargetRuntimeCompatibility', () => {
|
||||
test('supports native Codex default sessions', () => {
|
||||
expect(
|
||||
evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'default',
|
||||
}).supported
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('supports Codex CLIProxy provider sessions only for provider codex', () => {
|
||||
expect(
|
||||
evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: 'codex',
|
||||
isComposite: false,
|
||||
}).supported
|
||||
).toBe(true);
|
||||
|
||||
const unsupported = evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: 'gemini',
|
||||
isComposite: false,
|
||||
});
|
||||
expect(unsupported.supported).toBe(false);
|
||||
expect(unsupported.reason).toMatch(/only supports CLIProxy provider "codex"/);
|
||||
});
|
||||
|
||||
test('rejects composite CLIProxy variants on Codex target', () => {
|
||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: 'codex',
|
||||
isComposite: true,
|
||||
});
|
||||
|
||||
expect(compatibility.supported).toBe(false);
|
||||
expect(compatibility.reason).toMatch(/does not support composite CLIProxy variants/);
|
||||
});
|
||||
|
||||
test('supports only Codex bridge API profiles on Codex target', () => {
|
||||
expect(
|
||||
evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'settings',
|
||||
cliproxyBridgeProvider: 'codex',
|
||||
}).supported
|
||||
).toBe(true);
|
||||
|
||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'settings',
|
||||
cliproxyBridgeProvider: 'gemini',
|
||||
});
|
||||
expect(compatibility.supported).toBe(false);
|
||||
expect(compatibility.reason).toMatch(/only supports CLIProxy Codex bridge profiles/);
|
||||
|
||||
const genericSettingsCompatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'settings',
|
||||
});
|
||||
expect(genericSettingsCompatibility.supported).toBe(false);
|
||||
expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/);
|
||||
});
|
||||
|
||||
test('rejects account and copilot profiles on Codex target', () => {
|
||||
expect(
|
||||
evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'account',
|
||||
}).supported
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'copilot',
|
||||
}).supported
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,602 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
CodexRawConfigConflictError,
|
||||
CodexRawConfigValidationError,
|
||||
getCodexDashboardDiagnostics,
|
||||
getCodexRawConfig,
|
||||
patchCodexConfig,
|
||||
resolveCodexConfigPaths,
|
||||
saveCodexRawConfig,
|
||||
summarizeCodexFeatureFlags,
|
||||
summarizeCodexMcpServers,
|
||||
summarizeCodexModelProviders,
|
||||
summarizeCodexProjectTrust,
|
||||
} from '../../../src/web-server/services/codex-dashboard-service';
|
||||
|
||||
const testRoot = path.join(os.tmpdir(), `ccs-codex-dashboard-test-${Date.now()}`);
|
||||
const codexHome = path.join(testRoot, '.codex-home');
|
||||
const codexStubPath = path.join(testRoot, 'codex');
|
||||
|
||||
function writeCodexStub(options?: { helpText?: string; version?: string }) {
|
||||
const helpText =
|
||||
options?.helpText ?? ' -c, --config <key=value>\n -p, --profile <CONFIG_PROFILE>\n';
|
||||
const version = options?.version ?? 'codex-cli 0.118.0-alpha.3';
|
||||
|
||||
fs.writeFileSync(
|
||||
codexStubPath,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then
|
||||
printf '%s\\n' "${version}"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "--help" ]; then
|
||||
printf '%s' "${helpText}"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
`
|
||||
);
|
||||
fs.chmodSync(codexStubPath, 0o755);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(testRoot, { recursive: true });
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
writeCodexStub();
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.CCS_CODEX_PATH = codexStubPath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CODEX_HOME;
|
||||
delete process.env.CCS_CODEX_PATH;
|
||||
if (fs.existsSync(testRoot)) {
|
||||
fs.rmSync(testRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('codex-dashboard-service', () => {
|
||||
it('resolves codex config paths with CODEX_HOME override', () => {
|
||||
const resolved = resolveCodexConfigPaths({
|
||||
env: {
|
||||
CODEX_HOME: './custom-codex-home',
|
||||
} as NodeJS.ProcessEnv,
|
||||
homeDir: '/Users/tester',
|
||||
});
|
||||
|
||||
expect(resolved.baseDir).toBe(path.resolve('./custom-codex-home'));
|
||||
expect(resolved.baseDirDisplay).toBe('$CODEX_HOME');
|
||||
expect(resolved.configPath).toBe(path.join(path.resolve('./custom-codex-home'), 'config.toml'));
|
||||
expect(resolved.configDisplayPath).toBe('$CODEX_HOME/config.toml');
|
||||
});
|
||||
|
||||
it('summarizes model providers with auth and header metadata', () => {
|
||||
const summary = summarizeCodexModelProviders({
|
||||
cliproxy: {
|
||||
base_url: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
env_key: 'CLIPROXY_API_KEY',
|
||||
wire_api: 'responses',
|
||||
http_headers: { 'x-test': '1' },
|
||||
},
|
||||
local: {
|
||||
base_url: 'http://localhost:11434/v1',
|
||||
experimental_bearer_token: 'secret',
|
||||
supports_websockets: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.length).toBe(2);
|
||||
expect(summary[0].name).toBe('cliproxy');
|
||||
expect(summary[0].envKey).toBe('CLIPROXY_API_KEY');
|
||||
expect(summary[0].hasHttpHeaders).toBe(true);
|
||||
expect(summary[1].usesExperimentalBearerToken).toBe(true);
|
||||
});
|
||||
|
||||
it('summarizes feature flags, project trust, and mcp servers', () => {
|
||||
const features = summarizeCodexFeatureFlags({
|
||||
multi_agent: true,
|
||||
shell_snapshot: false,
|
||||
custom_mode: 'beta',
|
||||
});
|
||||
const projects = summarizeCodexProjectTrust({
|
||||
'/tmp/a': { trust_level: 'trusted' },
|
||||
'/tmp/b': { trust_level: 'untrusted' },
|
||||
});
|
||||
const servers = summarizeCodexMcpServers({
|
||||
stdio: {
|
||||
command: 'npx',
|
||||
enabled_tools: ['browser_snapshot'],
|
||||
},
|
||||
remote: {
|
||||
url: 'https://example.test/mcp',
|
||||
bearer_token: 'not-allowed-inline',
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(features.enabled.map((feature) => feature.name)).toEqual(['multi_agent']);
|
||||
expect(features.disabled.map((feature) => feature.name)).toEqual(['shell_snapshot']);
|
||||
expect(features.all.find((feature) => feature.name === 'custom_mode')?.state).toBe('custom');
|
||||
expect(projects.length).toBe(2);
|
||||
expect(projects[0].trustLevel).toBe('trusted');
|
||||
expect(servers[0].transport).toBe('streamable-http');
|
||||
expect(servers[0].usesInlineBearerToken).toBe(true);
|
||||
});
|
||||
|
||||
it('returns raw config payload for missing config.toml', async () => {
|
||||
const raw = await getCodexRawConfig();
|
||||
|
||||
expect(raw.exists).toBe(false);
|
||||
expect(raw.path).toBe('$CODEX_HOME/config.toml');
|
||||
expect(raw.rawText).toBe('');
|
||||
expect(raw.config).toBeNull();
|
||||
expect(raw.readError).toBeNull();
|
||||
});
|
||||
|
||||
it('returns parseError when config.toml is invalid TOML', async () => {
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n[features\n');
|
||||
|
||||
const raw = await getCodexRawConfig();
|
||||
|
||||
expect(raw.exists).toBe(true);
|
||||
expect(raw.parseError).toBeString();
|
||||
expect(raw.config).toBeNull();
|
||||
});
|
||||
|
||||
it('returns readError when config.toml is a symlink', async () => {
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
const targetPath = path.join(testRoot, 'linked.toml');
|
||||
fs.writeFileSync(targetPath, 'model = "gpt-5.4"\n');
|
||||
fs.symlinkSync(targetPath, configPath);
|
||||
|
||||
const raw = await getCodexRawConfig();
|
||||
|
||||
expect(raw.exists).toBe(true);
|
||||
expect(raw.readError).toContain('Refusing symlink file');
|
||||
expect(raw.config).toBeNull();
|
||||
});
|
||||
|
||||
it('includes docs links, support matrix, and config summaries in diagnostics', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
`model = "gpt-5.4"
|
||||
profile = "work"
|
||||
model_provider = "cliproxy"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "danger-full-access"
|
||||
web_search = "live"
|
||||
|
||||
[features]
|
||||
multi_agent = true
|
||||
shell_snapshot = false
|
||||
runtime_metrics = true
|
||||
|
||||
[model_providers.cliproxy]
|
||||
name = "CLIProxyAPI"
|
||||
base_url = "http://127.0.0.1:8317/api/provider/codex"
|
||||
env_key = "CLIPROXY_API_KEY"
|
||||
wire_api = "responses"
|
||||
|
||||
[projects."/tmp/project-a"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."/tmp/project-b"]
|
||||
trust_level = "untrusted"
|
||||
|
||||
[mcp_servers.playwright]
|
||||
command = "npx"
|
||||
args = ["@playwright/mcp@latest"]
|
||||
enabled_tools = ["browser_snapshot"]
|
||||
tool_timeout_sec = 30
|
||||
|
||||
[profiles.work]
|
||||
model = "gpt-5.4"
|
||||
`
|
||||
);
|
||||
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
|
||||
expect(diagnostics.binary.installed).toBe(true);
|
||||
expect(diagnostics.binary.supportsConfigOverrides).toBe(true);
|
||||
expect(diagnostics.config.model).toBe('gpt-5.4');
|
||||
expect(diagnostics.config.activeProfile).toBe('work');
|
||||
expect(diagnostics.config.modelProvider).toBe('cliproxy');
|
||||
expect(diagnostics.config.profileCount).toBe(1);
|
||||
expect(diagnostics.config.modelProviderCount).toBe(1);
|
||||
expect(diagnostics.config.featureCount).toBe(3);
|
||||
expect(diagnostics.config.enabledFeatures.map((feature) => feature.name)).toEqual([
|
||||
'multi_agent',
|
||||
'runtime_metrics',
|
||||
]);
|
||||
expect(diagnostics.config.trustedProjectCount).toBe(1);
|
||||
expect(diagnostics.config.untrustedProjectCount).toBe(1);
|
||||
expect(diagnostics.config.mcpServerCount).toBe(1);
|
||||
expect(diagnostics.docsReference.links.length).toBeGreaterThan(0);
|
||||
expect(diagnostics.supportMatrix.some((entry) => entry.id === 'default')).toBe(true);
|
||||
});
|
||||
|
||||
it('summarizes granular approval policies without flattening them to null', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
'approval_policy = { granular = { edit = "on-request" } }\n'
|
||||
);
|
||||
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
|
||||
expect(diagnostics.config.approvalPolicy).toBe('granular (custom)');
|
||||
});
|
||||
|
||||
it('warns when active profile is missing, config overrides are unavailable, or risky fields exist', async () => {
|
||||
writeCodexStub({ helpText: ' -p, --profile <CONFIG_PROFILE>\n' });
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
`profile = "missing-profile"
|
||||
|
||||
[model_providers.local]
|
||||
experimental_bearer_token = "secret"
|
||||
|
||||
[mcp_servers.remote]
|
||||
url = "https://example.test/mcp"
|
||||
bearer_token = "secret"
|
||||
`
|
||||
);
|
||||
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
|
||||
expect(
|
||||
diagnostics.warnings.some((warning) => warning.includes('does not expose --config overrides'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
diagnostics.warnings.some((warning) => warning.includes('missing from [profiles]'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
diagnostics.warnings.some((warning) => warning.includes('experimental_bearer_token'))
|
||||
).toBe(true);
|
||||
expect(diagnostics.warnings.some((warning) => warning.includes('inline bearer_token'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('saves valid raw config content', async () => {
|
||||
const result = await saveCodexRawConfig({
|
||||
rawText: 'model = "gpt-5.4"\n[features]\nmulti_agent = true\n',
|
||||
});
|
||||
|
||||
const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mtime).toBeGreaterThan(0);
|
||||
expect(written).toContain('model = "gpt-5.4"');
|
||||
});
|
||||
|
||||
it('rejects invalid TOML while saving raw config', async () => {
|
||||
await expect(saveCodexRawConfig({ rawText: 'model = "gpt-5.4"\n[features\n' })).rejects.toThrow(
|
||||
CodexRawConfigValidationError
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects stale writes with conflict error', async () => {
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
fs.writeFileSync(configPath, 'model = "gpt-5.4"\n');
|
||||
|
||||
await expect(
|
||||
saveCodexRawConfig({
|
||||
rawText: 'model = "gpt-5.3-codex"\n',
|
||||
expectedMtime: 1,
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigConflictError);
|
||||
});
|
||||
|
||||
it('rejects writes when expectedMtime differs by even 1ms', async () => {
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
fs.writeFileSync(configPath, 'model = "gpt-5.4"\n');
|
||||
|
||||
const current = await getCodexRawConfig();
|
||||
|
||||
await expect(
|
||||
saveCodexRawConfig({
|
||||
rawText: 'model = "gpt-5.4"\nprofile = "work"\n',
|
||||
expectedMtime: current.mtime + 1,
|
||||
})
|
||||
).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: 'friendly',
|
||||
},
|
||||
});
|
||||
|
||||
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('friendly');
|
||||
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('allows structured patches on existing config.toml even when expectedMtime is omitted', async () => {
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
|
||||
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(result.rawText).toContain('[features]');
|
||||
expect(result.rawText).toContain('multi_agent = true');
|
||||
expect(result.config?.features).toEqual({ multi_agent: true });
|
||||
});
|
||||
|
||||
it('preserves unsupported approval_policy objects when structured saves touch other fields', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
'model = "gpt-5.4"\napproval_policy = { granular = { edit = "on-request" } }\n'
|
||||
);
|
||||
|
||||
const current = await getCodexRawConfig();
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'top-level',
|
||||
expectedMtime: current.mtime,
|
||||
values: {
|
||||
model: 'gpt-5.4-mini',
|
||||
approvalPolicy: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('model = "gpt-5.4-mini"');
|
||||
expect(result.rawText).toContain('[approval_policy.granular]');
|
||||
expect(result.rawText).toContain('edit = "on-request"');
|
||||
expect(result.config?.approval_policy).toEqual({
|
||||
granular: { edit: 'on-request' },
|
||||
});
|
||||
});
|
||||
|
||||
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('accepts non-integer MCP timeout values documented by upstream Codex', async () => {
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
action: 'upsert',
|
||||
name: 'streaming',
|
||||
values: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
startupTimeoutSec: 1.5,
|
||||
toolTimeoutSec: 2.25,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('startup_timeout_sec = 1.5');
|
||||
expect(result.rawText).toContain('tool_timeout_sec = 2.25');
|
||||
});
|
||||
|
||||
it('rewrites legacy startup_timeout_ms keys when editing MCP server timeouts', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
['[mcp_servers.streaming]', 'command = "npx"', 'startup_timeout_ms = 1500', ''].join('\n')
|
||||
);
|
||||
const raw = await getCodexRawConfig();
|
||||
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
action: 'upsert',
|
||||
name: 'streaming',
|
||||
expectedMtime: raw.mtime,
|
||||
values: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
startupTimeoutSec: 2.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('startup_timeout_sec = 2.5');
|
||||
expect(result.rawText).not.toContain('startup_timeout_ms');
|
||||
});
|
||||
|
||||
it('patches streamable-http mcp servers through structured controls', async () => {
|
||||
const result = await patchCodexConfig({
|
||||
kind: 'mcp-server',
|
||||
action: 'upsert',
|
||||
name: 'remote',
|
||||
values: {
|
||||
transport: 'streamable-http',
|
||||
url: 'https://example.test/mcp',
|
||||
enabled: true,
|
||||
required: true,
|
||||
toolTimeoutSec: 45,
|
||||
enabledTools: ['browser_snapshot'],
|
||||
disabledTools: ['slow_tool'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rawText).toContain('[mcp_servers.remote]');
|
||||
expect(result.rawText).toContain('url = "https://example.test/mcp"');
|
||||
expect(result.rawText).toContain('required = true');
|
||||
|
||||
const diagnostics = await getCodexDashboardDiagnostics();
|
||||
expect(diagnostics.config.mcpServers).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'remote',
|
||||
transport: 'streamable-http',
|
||||
required: true,
|
||||
toolTimeoutSec: 45,
|
||||
enabledToolsCount: 1,
|
||||
disabledToolsCount: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('rejects invalid enum values even when they already exist in config.toml', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
'model = "gpt-5.4"\napproval_policy = "legacy"\n'
|
||||
);
|
||||
|
||||
await expect(
|
||||
patchCodexConfig({
|
||||
kind: 'top-level',
|
||||
values: {
|
||||
approvalPolicy: 'legacy' as unknown as 'on-request' | 'never' | 'untrusted' | null,
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(CodexRawConfigValidationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
readError: string | null;
|
||||
};
|
||||
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.exists).toBe(true);
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
expect(json.parseError).toBeNull();
|
||||
expect(json.readError).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('allows PATCH /config/patch on an existing config.toml without expectedMtime', async () => {
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\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,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
rawText: string;
|
||||
config: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.rawText).toContain('multi_agent = true');
|
||||
expect(json.config?.features).toEqual({ multi_agent: true });
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,10 @@ describe('route target parsing', () => {
|
||||
|
||||
it('returns null for invalid target values', () => {
|
||||
expect(parseProfileTarget('glm')).toBeNull();
|
||||
expect(parseProfileTarget('codex')).toBeNull();
|
||||
expect(parseProfileTarget('')).toBeNull();
|
||||
expect(parseVariantTarget('factory')).toBeNull();
|
||||
expect(parseVariantTarget('codex')).toBeNull();
|
||||
expect(parseVariantTarget(' ')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user