mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
feat(targets): add native codex runtime target
- add a Codex adapter, detector, runtime aliases, and compatibility matrix - keep Codex runtime-only while preserving persisted targets for claude and droid - cover Codex launch, reasoning, wrapper detection, and bridge routing regressions Refs #773
This commit is contained in:
1 parent
9307bb857f
commit
8f60820f33
18 files changed
+1254
-54
No files matched your search
+3
-1
@@ -28,7 +28,9 @@
|
|||||||
"bin": {
|
"bin": {
|
||||||
"ccs": "dist/ccs.js",
|
"ccs": "dist/ccs.js",
|
||||||
"ccs-droid": "dist/bin/droid-runtime.js",
|
"ccs-droid": "dist/bin/droid-runtime.js",
|
||||||
"ccsd": "dist/bin/droid-runtime.js"
|
"ccsd": "dist/bin/droid-runtime.js",
|
||||||
|
"ccs-codex": "dist/bin/codex-runtime.js",
|
||||||
|
"ccsx": "dist/bin/codex-runtime.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||||
|
require('../ccs');
|
||||||
+88
-24
@@ -56,6 +56,8 @@ import {
|
|||||||
getTarget,
|
getTarget,
|
||||||
ClaudeAdapter,
|
ClaudeAdapter,
|
||||||
DroidAdapter,
|
DroidAdapter,
|
||||||
|
CodexAdapter,
|
||||||
|
evaluateTargetRuntimeCompatibility,
|
||||||
pruneOrphanedModels,
|
pruneOrphanedModels,
|
||||||
resolveDroidProvider,
|
resolveDroidProvider,
|
||||||
type TargetCredentials,
|
type TargetCredentials,
|
||||||
@@ -66,6 +68,7 @@ import {
|
|||||||
resolveDroidReasoningRuntime,
|
resolveDroidReasoningRuntime,
|
||||||
} from './targets/droid-reasoning-runtime';
|
} from './targets/droid-reasoning-runtime';
|
||||||
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
|
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
|
||||||
|
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
|
||||||
|
|
||||||
// Version and Update check utilities
|
// Version and Update check utilities
|
||||||
import { getVersion } from './utils/version';
|
import { getVersion } from './utils/version';
|
||||||
@@ -183,6 +186,7 @@ async function main(): Promise<void> {
|
|||||||
// Register target adapters
|
// Register target adapters
|
||||||
registerTarget(new ClaudeAdapter());
|
registerTarget(new ClaudeAdapter());
|
||||||
registerTarget(new DroidAdapter());
|
registerTarget(new DroidAdapter());
|
||||||
|
registerTarget(new CodexAdapter());
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
@@ -381,21 +385,25 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) {
|
if (profileInfo.type !== 'settings') {
|
||||||
console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`));
|
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||||
console.error(info('Use a settings-based profile with --target instead'));
|
target: resolvedTarget,
|
||||||
process.exit(1);
|
profileType: profileInfo.type,
|
||||||
}
|
cliproxyProvider: profileInfo.type === 'cliproxy' ? profileInfo.provider : undefined,
|
||||||
|
isComposite:
|
||||||
if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) {
|
profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined,
|
||||||
console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`));
|
});
|
||||||
process.exit(1);
|
if (!compatibility.supported) {
|
||||||
}
|
console.error(
|
||||||
|
fail(
|
||||||
if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) {
|
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
|
||||||
console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`));
|
)
|
||||||
console.error(info('Use a settings-based profile with --target instead'));
|
);
|
||||||
process.exit(1);
|
if (compatibility.suggestion) {
|
||||||
|
console.error(info(compatibility.suggestion));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profileInfo.type === 'default') {
|
if (profileInfo.type === 'default') {
|
||||||
@@ -428,6 +436,8 @@ async function main(): Promise<void> {
|
|||||||
console.error(fail(`${displayName} CLI not found.`));
|
console.error(fail(`${displayName} CLI not found.`));
|
||||||
if (resolvedTarget === 'droid') {
|
if (resolvedTarget === 'droid') {
|
||||||
console.error(info('Install: npm i -g @factory/cli'));
|
console.error(info('Install: npm i -g @factory/cli'));
|
||||||
|
} else if (resolvedTarget === 'codex') {
|
||||||
|
console.error(info('Install a recent @openai/codex build, then retry.'));
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -446,7 +456,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetRemainingArgs = remainingArgs;
|
let targetRemainingArgs = remainingArgs;
|
||||||
let droidReasoningOverride: string | number | undefined;
|
let runtimeReasoningOverride: string | number | undefined;
|
||||||
if (resolvedTarget === 'droid') {
|
if (resolvedTarget === 'droid') {
|
||||||
try {
|
try {
|
||||||
const droidRoute = routeDroidCommandArgs(remainingArgs);
|
const droidRoute = routeDroidCommandArgs(remainingArgs);
|
||||||
@@ -455,7 +465,7 @@ async function main(): Promise<void> {
|
|||||||
if (droidRoute.mode === 'interactive') {
|
if (droidRoute.mode === 'interactive') {
|
||||||
const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING);
|
const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING);
|
||||||
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
||||||
droidReasoningOverride = runtime.reasoningOverride;
|
runtimeReasoningOverride = runtime.reasoningOverride;
|
||||||
|
|
||||||
if (runtime.duplicateDisplays.length > 0) {
|
if (runtime.duplicateDisplays.length > 0) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -488,6 +498,28 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
} else if (resolvedTarget === 'codex') {
|
||||||
|
try {
|
||||||
|
const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING);
|
||||||
|
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
|
||||||
|
runtimeReasoningOverride = runtime.reasoningOverride;
|
||||||
|
|
||||||
|
if (runtime.duplicateDisplays.length > 0) {
|
||||||
|
console.error(
|
||||||
|
warn(
|
||||||
|
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DroidReasoningFlagError) {
|
||||||
|
console.error(fail(error.message));
|
||||||
|
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
||||||
|
console.error(' Codex alias: --effort minimal|low|medium|high|xhigh');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case: headless delegation (-p/--prompt)
|
// Special case: headless delegation (-p/--prompt)
|
||||||
@@ -625,7 +657,7 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: envVars['ANTHROPIC_BASE_URL'],
|
baseUrl: envVars['ANTHROPIC_BASE_URL'],
|
||||||
model: envVars['ANTHROPIC_MODEL'],
|
model: envVars['ANTHROPIC_MODEL'],
|
||||||
}),
|
}),
|
||||||
reasoningOverride: droidReasoningOverride,
|
reasoningOverride: runtimeReasoningOverride,
|
||||||
envVars,
|
envVars,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -643,7 +675,11 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await adapter.prepareCredentials(creds);
|
await adapter.prepareCredentials(creds);
|
||||||
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
|
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
|
||||||
|
creds,
|
||||||
|
profileType: profileInfo.type,
|
||||||
|
binaryInfo: targetBinaryInfo || undefined,
|
||||||
|
});
|
||||||
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
||||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||||
return;
|
return;
|
||||||
@@ -722,6 +758,26 @@ async function main(): Promise<void> {
|
|||||||
? expandPath(profileInfo.settingsPath)
|
? expandPath(profileInfo.settingsPath)
|
||||||
: getSettingsPath(profileInfo.name);
|
: getSettingsPath(profileInfo.name);
|
||||||
const settings = loadSettings(expandedSettingsPath);
|
const settings = loadSettings(expandedSettingsPath);
|
||||||
|
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
||||||
|
if (resolvedTarget !== 'claude') {
|
||||||
|
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||||
|
target: resolvedTarget,
|
||||||
|
profileType: profileInfo.type,
|
||||||
|
cliproxyBridgeProvider: cliproxyBridge?.provider ?? null,
|
||||||
|
});
|
||||||
|
if (!compatibility.supported) {
|
||||||
|
console.error(
|
||||||
|
fail(
|
||||||
|
compatibility.reason ||
|
||||||
|
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (compatibility.suggestion) {
|
||||||
|
console.error(info(compatibility.suggestion));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
const rawSettingsEnv = profileInfo.env ?? settings.env ?? {};
|
const rawSettingsEnv = profileInfo.env ?? settings.env ?? {};
|
||||||
const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name);
|
const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name);
|
||||||
const glmtNormalization = isDeprecatedGlmtProfile
|
const glmtNormalization = isDeprecatedGlmtProfile
|
||||||
@@ -841,11 +897,15 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: directAnthropicBaseUrl,
|
baseUrl: directAnthropicBaseUrl,
|
||||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||||
}),
|
}),
|
||||||
reasoningOverride: droidReasoningOverride,
|
reasoningOverride: runtimeReasoningOverride,
|
||||||
envVars,
|
envVars,
|
||||||
};
|
};
|
||||||
await adapter.prepareCredentials(creds);
|
await adapter.prepareCredentials(creds);
|
||||||
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
|
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs, {
|
||||||
|
creds,
|
||||||
|
profileType: profileInfo.type,
|
||||||
|
binaryInfo: targetBinaryInfo || undefined,
|
||||||
|
});
|
||||||
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
||||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||||
return;
|
return;
|
||||||
@@ -938,9 +998,9 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: process.env['ANTHROPIC_BASE_URL'],
|
baseUrl: process.env['ANTHROPIC_BASE_URL'],
|
||||||
model: process.env['ANTHROPIC_MODEL'],
|
model: process.env['ANTHROPIC_MODEL'],
|
||||||
}),
|
}),
|
||||||
reasoningOverride: droidReasoningOverride,
|
reasoningOverride: runtimeReasoningOverride,
|
||||||
};
|
};
|
||||||
if (!creds.baseUrl || !creds.apiKey) {
|
if (resolvedTarget === 'droid' && (!creds.baseUrl || !creds.apiKey)) {
|
||||||
console.error(
|
console.error(
|
||||||
fail(
|
fail(
|
||||||
`${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
`${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
||||||
@@ -950,7 +1010,11 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
await adapter.prepareCredentials(creds);
|
await adapter.prepareCredentials(creds);
|
||||||
const targetArgs = adapter.buildArgs('default', targetRemainingArgs);
|
const targetArgs = adapter.buildArgs('default', targetRemainingArgs, {
|
||||||
|
creds,
|
||||||
|
profileType: 'default',
|
||||||
|
binaryInfo: targetBinaryInfo || undefined,
|
||||||
|
});
|
||||||
const targetEnv = adapter.buildEnv(creds, 'default');
|
const targetEnv = adapter.buildEnv(creds, 'default');
|
||||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { ChildProcess, spawn } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import type { ProfileType } from '../types/profile';
|
||||||
|
import { runCleanup } from '../errors';
|
||||||
|
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||||
|
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
|
||||||
|
import type {
|
||||||
|
TargetAdapter,
|
||||||
|
TargetBinaryInfo,
|
||||||
|
TargetCredentials,
|
||||||
|
TargetType,
|
||||||
|
} from './target-adapter';
|
||||||
|
import {
|
||||||
|
codexBinarySupportsConfigOverrides,
|
||||||
|
detectCodexCli,
|
||||||
|
getCodexBinaryInfo,
|
||||||
|
} from './codex-detector';
|
||||||
|
|
||||||
|
const CODEX_RUNTIME_PROVIDER_ID = 'ccs_runtime';
|
||||||
|
const CODEX_RUNTIME_ENV_KEY = 'CCS_CODEX_API_KEY';
|
||||||
|
const CODEX_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||||
|
|
||||||
|
function formatTomlString(value: string): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigOverrideArgs(overrides: string[]): string[] {
|
||||||
|
return overrides.flatMap((override) => ['-c', override]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDisallowedCodexManagedFlags(args: string[]): string[] {
|
||||||
|
const disallowed = new Set<string>();
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg === '-c' || arg === '--config' || arg.startsWith('--config=')) {
|
||||||
|
disallowed.add('--config/-c');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === '-p' || arg === '--profile' || arg.startsWith('--profile=')) {
|
||||||
|
disallowed.add('--profile/-p');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === '--oss') {
|
||||||
|
disallowed.add('--oss');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === '--local-provider' || arg.startsWith('--local-provider=')) {
|
||||||
|
disallowed.add('--local-provider');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...disallowed];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodexReasoningOverride(value: string | number | undefined): string | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && CODEX_REASONING_LEVELS.has(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodexAdapter implements TargetAdapter {
|
||||||
|
readonly type: TargetType = 'codex';
|
||||||
|
readonly displayName = 'Codex CLI';
|
||||||
|
|
||||||
|
detectBinary(): TargetBinaryInfo | null {
|
||||||
|
return getCodexBinaryInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
|
||||||
|
// Codex uses transient -c overrides plus env_key injection.
|
||||||
|
}
|
||||||
|
|
||||||
|
buildArgs(
|
||||||
|
_profile: string,
|
||||||
|
userArgs: string[],
|
||||||
|
options?: {
|
||||||
|
creds?: TargetCredentials;
|
||||||
|
profileType?: ProfileType;
|
||||||
|
binaryInfo?: TargetBinaryInfo;
|
||||||
|
}
|
||||||
|
): string[] {
|
||||||
|
const profileType = options?.profileType || 'default';
|
||||||
|
const creds = options?.creds;
|
||||||
|
const reasoningOverride = normalizeCodexReasoningOverride(creds?.reasoningOverride);
|
||||||
|
|
||||||
|
if (profileType === 'default') {
|
||||||
|
if (reasoningOverride) {
|
||||||
|
return [
|
||||||
|
...buildConfigOverrideArgs([
|
||||||
|
`model_reasoning_effort=${formatTomlString(reasoningOverride)}`,
|
||||||
|
]),
|
||||||
|
...userArgs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return userArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) {
|
||||||
|
const versionSummary = options?.binaryInfo?.version ? ` (${options.binaryInfo.version})` : '';
|
||||||
|
throw new Error(
|
||||||
|
`Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
'Codex target requires base URL and API key for CCS-backed profile launches.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disallowedFlags = findDisallowedCodexManagedFlags(userArgs);
|
||||||
|
if (disallowedFlags.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Codex target does not allow ${disallowedFlags.join(', ')} when CCS manages the runtime provider. Remove native Codex provider selection flags and retry.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = [
|
||||||
|
`model_provider=${formatTomlString(CODEX_RUNTIME_PROVIDER_ID)}`,
|
||||||
|
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.name=${formatTomlString('CCS Runtime')}`,
|
||||||
|
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString(creds.baseUrl)}`,
|
||||||
|
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.env_key=${formatTomlString(CODEX_RUNTIME_ENV_KEY)}`,
|
||||||
|
`model_providers.${CODEX_RUNTIME_PROVIDER_ID}.wire_api=${formatTomlString('responses')}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (creds.model?.trim()) {
|
||||||
|
overrides.push(`model=${formatTomlString(creds.model)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasoningOverride) {
|
||||||
|
overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...buildConfigOverrideArgs(overrides), ...userArgs];
|
||||||
|
}
|
||||||
|
|
||||||
|
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...stripAnthropicEnv(process.env) };
|
||||||
|
delete env[CODEX_RUNTIME_ENV_KEY];
|
||||||
|
if (profileType !== 'default') {
|
||||||
|
if (!creds.apiKey?.trim()) {
|
||||||
|
throw new Error('Codex target requires an API key for CCS-backed profile launches.');
|
||||||
|
}
|
||||||
|
env[CODEX_RUNTIME_ENV_KEY] = creds.apiKey;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
|
||||||
|
): void {
|
||||||
|
const exitWithCleanup = (code: number): never => {
|
||||||
|
try {
|
||||||
|
runCleanup();
|
||||||
|
} catch {
|
||||||
|
// Cleanup is best-effort on launch errors.
|
||||||
|
}
|
||||||
|
process.exit(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const codexPath = options?.binaryInfo?.path || detectCodexCli();
|
||||||
|
if (!codexPath) {
|
||||||
|
console.error('[X] Codex CLI not found. Install a recent @openai/codex build first.');
|
||||||
|
return exitWithCleanup(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(codexPath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
console.error(`[X] Codex CLI path is not a file: ${codexPath}`);
|
||||||
|
return exitWithCleanup(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as NodeJS.ErrnoException;
|
||||||
|
console.error(
|
||||||
|
`[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}`
|
||||||
|
);
|
||||||
|
return exitWithCleanup(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
|
||||||
|
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
|
||||||
|
|
||||||
|
let child: ChildProcess;
|
||||||
|
if (isPowerShellScript) {
|
||||||
|
child = spawn(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args],
|
||||||
|
{ stdio: 'inherit', windowsHide: true, env }
|
||||||
|
);
|
||||||
|
} else if (needsShell) {
|
||||||
|
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
|
||||||
|
child = spawn(cmdString, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
windowsHide: true,
|
||||||
|
shell: true,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
child = spawn(codexPath, args, { stdio: 'inherit', windowsHide: true, env });
|
||||||
|
}
|
||||||
|
|
||||||
|
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === 'EACCES') {
|
||||||
|
console.error(`[X] Codex CLI is not executable: ${codexPath}`);
|
||||||
|
console.error(' Check file permissions and executable bit.');
|
||||||
|
} else if (err.code === 'ENOENT') {
|
||||||
|
if (isPowerShellScript) {
|
||||||
|
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
|
||||||
|
} else if (needsShell) {
|
||||||
|
console.error('[X] Windows command shell not found for Codex wrapper launch.');
|
||||||
|
} else {
|
||||||
|
console.error(`[X] Codex CLI not found: ${codexPath}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[X] Failed to start Codex CLI (${codexPath}): ${err.message}`);
|
||||||
|
}
|
||||||
|
return exitWithCleanup(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsProfileType(profileType: ProfileType): boolean {
|
||||||
|
return profileType === 'default' || profileType === 'settings' || profileType === 'cliproxy';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as childProcess from 'child_process';
|
||||||
|
import { expandPath } from '../utils/helpers';
|
||||||
|
import { escapeShellArg } from '../utils/shell-executor';
|
||||||
|
import type { TargetBinaryInfo } from './target-adapter';
|
||||||
|
|
||||||
|
const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides';
|
||||||
|
|
||||||
|
function runCodexProbe(codexPath: string, args: string[]): string | undefined {
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath);
|
||||||
|
const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isPowerShellScript) {
|
||||||
|
return childProcess.execFileSync(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
timeout: 5000,
|
||||||
|
windowsHide: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsShell) {
|
||||||
|
const cmdString = [codexPath, ...args].map(escapeShellArg).join(' ');
|
||||||
|
return childProcess.execFileSync('cmd.exe', ['/d', '/s', '/c', cmdString], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
timeout: 5000,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return childProcess.execFileSync(codexPath, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodexVersion(codexPath: string): string | undefined {
|
||||||
|
return runCodexProbe(codexPath, ['--version'])?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCodexFeatures(codexPath: string): readonly string[] {
|
||||||
|
const helpText = runCodexProbe(codexPath, ['--help']);
|
||||||
|
return helpText?.includes('--config <key=value>') ? [CODEX_CONFIG_OVERRIDE_FEATURE] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectCodexCli(): string | null {
|
||||||
|
if (process.env.CCS_CODEX_PATH) {
|
||||||
|
const customPath = expandPath(process.env.CCS_CODEX_PATH);
|
||||||
|
try {
|
||||||
|
if (fs.statSync(customPath).isFile()) {
|
||||||
|
return customPath;
|
||||||
|
}
|
||||||
|
console.warn('[!] CCS_CODEX_PATH points to a directory, not a file:', customPath);
|
||||||
|
console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.');
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as NodeJS.ErrnoException;
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
console.warn('[!] Warning: CCS_CODEX_PATH is set but file not found:', customPath);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[!] Warning: CCS_CODEX_PATH is not accessible (${error.code || 'unknown error'}):`,
|
||||||
|
customPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.warn(' Refusing PATH fallback while CCS_CODEX_PATH is explicitly set.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
try {
|
||||||
|
const cmd = isWindows ? 'where.exe codex' : 'which codex';
|
||||||
|
const result = childProcess
|
||||||
|
.execSync(cmd, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const matches = result
|
||||||
|
.split('\n')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const candidates = isWindows
|
||||||
|
? [
|
||||||
|
...matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)),
|
||||||
|
...matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)),
|
||||||
|
]
|
||||||
|
: matches;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.statSync(candidate).isFile()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore disappearing PATH candidates.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// codex not in PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodexBinaryInfo(): TargetBinaryInfo | null {
|
||||||
|
const codexPath = detectCodexCli();
|
||||||
|
if (!codexPath) return null;
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
return {
|
||||||
|
path: codexPath,
|
||||||
|
needsShell: isWindows && /\.(cmd|bat|ps1)$/i.test(codexPath),
|
||||||
|
version: readCodexVersion(codexPath),
|
||||||
|
features: detectCodexFeatures(codexPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function codexBinarySupportsConfigOverrides(
|
||||||
|
binaryInfo: TargetBinaryInfo | null | undefined
|
||||||
|
): boolean {
|
||||||
|
return Boolean(binaryInfo?.features?.includes(CODEX_CONFIG_OVERRIDE_FEATURE));
|
||||||
|
}
|
||||||
@@ -19,7 +19,13 @@ export {
|
|||||||
} from './target-registry';
|
} from './target-registry';
|
||||||
export { ClaudeAdapter } from './claude-adapter';
|
export { ClaudeAdapter } from './claude-adapter';
|
||||||
export { DroidAdapter } from './droid-adapter';
|
export { DroidAdapter } from './droid-adapter';
|
||||||
|
export { CodexAdapter } from './codex-adapter';
|
||||||
export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
|
export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
|
||||||
|
export {
|
||||||
|
codexBinarySupportsConfigOverrides,
|
||||||
|
getCodexBinaryInfo,
|
||||||
|
detectCodexCli,
|
||||||
|
} from './codex-detector';
|
||||||
export {
|
export {
|
||||||
upsertCcsModel,
|
upsertCcsModel,
|
||||||
removeCcsModel,
|
removeCcsModel,
|
||||||
@@ -30,3 +36,13 @@ export type { DroidCustomModel } from './droid-config-manager';
|
|||||||
export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider';
|
export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider';
|
||||||
export type { DroidProvider } from './droid-provider';
|
export type { DroidProvider } from './droid-provider';
|
||||||
export { resolveTargetType, stripTargetFlag } from './target-resolver';
|
export { resolveTargetType, stripTargetFlag } from './target-resolver';
|
||||||
|
export {
|
||||||
|
TARGET_METADATA,
|
||||||
|
RUNTIME_TARGET_TYPES,
|
||||||
|
PERSISTED_TARGET_TYPES,
|
||||||
|
getPersistedTargetChoices,
|
||||||
|
getRuntimeTargetChoices,
|
||||||
|
isPersistedTargetType,
|
||||||
|
isRuntimeTargetType,
|
||||||
|
} from './target-metadata';
|
||||||
|
export { evaluateTargetRuntimeCompatibility } from './target-runtime-compatibility';
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { ProfileType } from '../types/profile';
|
import type { ProfileType } from '../types/profile';
|
||||||
|
|
||||||
export type TargetType = 'claude' | 'droid';
|
export type TargetType = 'claude' | 'droid' | 'codex';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credentials resolved by CCS profile system, ready for delivery to target CLI.
|
* Credentials resolved by CCS profile system, ready for delivery to target CLI.
|
||||||
@@ -39,6 +39,8 @@ export interface TargetCredentials {
|
|||||||
export interface TargetBinaryInfo {
|
export interface TargetBinaryInfo {
|
||||||
path: string;
|
path: string;
|
||||||
needsShell: boolean; // Windows .cmd/.bat/.ps1
|
needsShell: boolean; // Windows .cmd/.bat/.ps1
|
||||||
|
version?: string;
|
||||||
|
features?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,7 +74,15 @@ export interface TargetAdapter {
|
|||||||
* Build target-specific argument vector.
|
* Build target-specific argument vector.
|
||||||
* `userArgs` are the arguments after CCS profile/flag parsing.
|
* `userArgs` are the arguments after CCS profile/flag parsing.
|
||||||
*/
|
*/
|
||||||
buildArgs(profile: string, userArgs: string[]): string[];
|
buildArgs(
|
||||||
|
profile: string,
|
||||||
|
userArgs: string[],
|
||||||
|
options?: {
|
||||||
|
creds?: TargetCredentials;
|
||||||
|
profileType?: ProfileType;
|
||||||
|
binaryInfo?: TargetBinaryInfo;
|
||||||
|
}
|
||||||
|
): string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build environment variables for process spawn.
|
* Build environment variables for process spawn.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { TargetType } from './target-adapter';
|
||||||
|
|
||||||
|
export interface TargetMetadata {
|
||||||
|
displayName: string;
|
||||||
|
runtimeAliases: readonly string[];
|
||||||
|
legacyAliasEnvVar?: string;
|
||||||
|
persistedTarget: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TARGET_METADATA: Record<TargetType, TargetMetadata> = {
|
||||||
|
claude: {
|
||||||
|
displayName: 'Claude Code',
|
||||||
|
runtimeAliases: [],
|
||||||
|
persistedTarget: true,
|
||||||
|
},
|
||||||
|
droid: {
|
||||||
|
displayName: 'Factory Droid',
|
||||||
|
runtimeAliases: ['ccs-droid', 'ccsd'],
|
||||||
|
legacyAliasEnvVar: 'CCS_DROID_ALIASES',
|
||||||
|
persistedTarget: true,
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
displayName: 'Codex CLI',
|
||||||
|
runtimeAliases: ['ccs-codex', 'ccsx'],
|
||||||
|
legacyAliasEnvVar: 'CCS_CODEX_ALIASES',
|
||||||
|
persistedTarget: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RUNTIME_TARGET_TYPES = Object.freeze(
|
||||||
|
Object.keys(TARGET_METADATA) as TargetType[]
|
||||||
|
) as readonly TargetType[];
|
||||||
|
|
||||||
|
export const PERSISTED_TARGET_TYPES = Object.freeze(
|
||||||
|
RUNTIME_TARGET_TYPES.filter((target) => TARGET_METADATA[target].persistedTarget)
|
||||||
|
) as readonly TargetType[];
|
||||||
|
|
||||||
|
const RUNTIME_TARGET_SET = new Set<TargetType>(RUNTIME_TARGET_TYPES);
|
||||||
|
const PERSISTED_TARGET_SET = new Set<TargetType>(PERSISTED_TARGET_TYPES);
|
||||||
|
|
||||||
|
export function isRuntimeTargetType(value: unknown): value is TargetType {
|
||||||
|
return typeof value === 'string' && RUNTIME_TARGET_SET.has(value as TargetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPersistedTargetType(value: unknown): value is TargetType {
|
||||||
|
return typeof value === 'string' && PERSISTED_TARGET_SET.has(value as TargetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTargetChoices(
|
||||||
|
targets: readonly TargetType[],
|
||||||
|
conjunction: 'or' | 'comma' = 'comma'
|
||||||
|
): string {
|
||||||
|
if (targets.length === 0) return '';
|
||||||
|
if (targets.length === 1) return targets[0];
|
||||||
|
if (conjunction === 'comma') return targets.join(', ');
|
||||||
|
if (targets.length === 2) return `${targets[0]} or ${targets[1]}`;
|
||||||
|
return `${targets.slice(0, -1).join(', ')}, or ${targets[targets.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersistedTargetChoices(): string {
|
||||||
|
return formatTargetChoices(PERSISTED_TARGET_TYPES, 'or');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeTargetChoices(): string {
|
||||||
|
return formatTargetChoices(RUNTIME_TARGET_TYPES, 'comma');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBuiltinArgv0TargetMap(): Record<string, TargetType> {
|
||||||
|
const map: Record<string, TargetType> = {};
|
||||||
|
for (const target of RUNTIME_TARGET_TYPES) {
|
||||||
|
for (const alias of TARGET_METADATA[target].runtimeAliases) {
|
||||||
|
map[alias] = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegacyTargetAliasEnvVars(): Partial<Record<TargetType, string>> {
|
||||||
|
const result: Partial<Record<TargetType, string>> = {};
|
||||||
|
for (const target of RUNTIME_TARGET_TYPES) {
|
||||||
|
const envVar = TARGET_METADATA[target].legacyAliasEnvVar;
|
||||||
|
if (envVar) {
|
||||||
|
result[target] = envVar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -10,21 +10,24 @@
|
|||||||
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { TargetType } from './target-adapter';
|
import { TargetType } from './target-adapter';
|
||||||
|
import {
|
||||||
|
getBuiltinArgv0TargetMap,
|
||||||
|
getLegacyTargetAliasEnvVars,
|
||||||
|
getRuntimeTargetChoices,
|
||||||
|
isPersistedTargetType,
|
||||||
|
isRuntimeTargetType,
|
||||||
|
} from './target-metadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in argv[0] aliases for explicit runtime entrypoints.
|
* Built-in argv[0] aliases for explicit runtime entrypoints.
|
||||||
* `ccs-droid` is the transparent alias; `ccsd` remains as a legacy shortcut.
|
* Droid and Codex install dedicated runtime aliases alongside the base `ccs` bin.
|
||||||
*/
|
*/
|
||||||
const BUILTIN_ARGV0_TARGET_MAP: Record<string, TargetType> = {
|
const BUILTIN_ARGV0_TARGET_MAP: Record<string, TargetType> = getBuiltinArgv0TargetMap();
|
||||||
'ccs-droid': 'droid',
|
|
||||||
ccsd: 'droid',
|
|
||||||
};
|
|
||||||
const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/;
|
const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/;
|
||||||
const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET';
|
const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET';
|
||||||
const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES';
|
const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES';
|
||||||
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> = {
|
const LEGACY_TARGET_ALIAS_ENV_VARS: Partial<Record<TargetType, string>> =
|
||||||
droid: 'CCS_DROID_ALIASES',
|
getLegacyTargetAliasEnvVars();
|
||||||
};
|
|
||||||
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
|
const RESERVED_BIN_NAMES = new Set<string>(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]);
|
||||||
|
|
||||||
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
|
function addAliasToMap(map: Record<string, TargetType>, alias: string, target: TargetType): void {
|
||||||
@@ -64,7 +67,7 @@ function parseGenericTargetAliasConfig(map: Record<string, TargetType>, rawConfi
|
|||||||
|
|
||||||
const rawTarget = entry.slice(0, separatorIndex).trim().toLowerCase();
|
const rawTarget = entry.slice(0, separatorIndex).trim().toLowerCase();
|
||||||
const rawAliases = entry.slice(separatorIndex + 1).trim();
|
const rawAliases = entry.slice(separatorIndex + 1).trim();
|
||||||
if (!rawAliases || !isValidTarget(rawTarget)) {
|
if (!rawAliases || !isRuntimeTargetType(rawTarget)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,30 +103,21 @@ function resolveEntrypointTarget(): TargetType | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedTarget = rawTarget.trim().toLowerCase();
|
const normalizedTarget = rawTarget.trim().toLowerCase();
|
||||||
return isValidTarget(normalizedTarget) ? normalizedTarget : null;
|
return isRuntimeTargetType(normalizedTarget) ? normalizedTarget : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid target types for --target flag validation.
|
|
||||||
*/
|
|
||||||
const VALID_TARGETS: ReadonlySet<string> = new Set<TargetType>(['claude', 'droid']);
|
|
||||||
|
|
||||||
interface ParsedTargetFlags {
|
interface ParsedTargetFlags {
|
||||||
targetOverride?: TargetType;
|
targetOverride?: TargetType;
|
||||||
cleanedArgs: string[];
|
cleanedArgs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidTarget(target: unknown): target is TargetType {
|
|
||||||
return typeof target === 'string' && VALID_TARGETS.has(target as TargetType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTargetValue(value: string): TargetType {
|
function normalizeTargetValue(value: string): TargetType {
|
||||||
const normalized = value.toLowerCase();
|
const normalized = value.toLowerCase();
|
||||||
if (isValidTarget(normalized)) {
|
if (isRuntimeTargetType(normalized)) {
|
||||||
return normalized as TargetType;
|
return normalized as TargetType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = Array.from(VALID_TARGETS).join(', ');
|
const available = getRuntimeTargetChoices();
|
||||||
throw new Error(`Unknown target "${value}". Available: ${available}`);
|
throw new Error(`Unknown target "${value}". Available: ${available}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +142,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags {
|
|||||||
if (arg === '--target') {
|
if (arg === '--target') {
|
||||||
const value = args[i + 1];
|
const value = args[i + 1];
|
||||||
if (!value || value.startsWith('-')) {
|
if (!value || value.startsWith('-')) {
|
||||||
throw new Error('--target requires a value (claude or droid)');
|
throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`);
|
||||||
}
|
}
|
||||||
targetOverride = normalizeTargetValue(value);
|
targetOverride = normalizeTargetValue(value);
|
||||||
i += 1; // Skip value
|
i += 1; // Skip value
|
||||||
@@ -158,7 +152,7 @@ function parseTargetFlags(args: string[]): ParsedTargetFlags {
|
|||||||
if (arg.startsWith('--target=')) {
|
if (arg.startsWith('--target=')) {
|
||||||
const value = arg.slice('--target='.length).trim();
|
const value = arg.slice('--target='.length).trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error('--target requires a value (claude or droid)');
|
throw new Error(`--target requires a value (${getRuntimeTargetChoices()})`);
|
||||||
}
|
}
|
||||||
targetOverride = normalizeTargetValue(value);
|
targetOverride = normalizeTargetValue(value);
|
||||||
continue;
|
continue;
|
||||||
@@ -204,7 +198,7 @@ export function resolveTargetType(
|
|||||||
|
|
||||||
// 3. Check per-profile config
|
// 3. Check per-profile config
|
||||||
if (profileConfig?.target !== undefined) {
|
if (profileConfig?.target !== undefined) {
|
||||||
return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude';
|
return isPersistedTargetType(profileConfig.target) ? profileConfig.target : 'claude';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Default
|
// 4. Default
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||||
|
import type { ProfileType } from '../types/profile';
|
||||||
|
import type { TargetType } from './target-adapter';
|
||||||
|
|
||||||
|
export interface TargetRuntimeCompatibilityInput {
|
||||||
|
target: TargetType;
|
||||||
|
profileType: ProfileType;
|
||||||
|
cliproxyProvider?: CLIProxyProvider;
|
||||||
|
cliproxyBridgeProvider?: CLIProxyProvider | null;
|
||||||
|
isComposite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetRuntimeCompatibilityResult {
|
||||||
|
supported: boolean;
|
||||||
|
reason?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsupported(reason: string, suggestion?: string): TargetRuntimeCompatibilityResult {
|
||||||
|
return { supported: false, reason, suggestion };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateTargetRuntimeCompatibility(
|
||||||
|
input: TargetRuntimeCompatibilityInput
|
||||||
|
): TargetRuntimeCompatibilityResult {
|
||||||
|
if (input.target === 'claude') {
|
||||||
|
return { supported: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.target === 'droid') {
|
||||||
|
if (input.profileType === 'account') {
|
||||||
|
return unsupported(
|
||||||
|
'Factory Droid does not support account-based Claude profiles.',
|
||||||
|
'Use a settings-based profile with --target droid instead.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.profileType === 'copilot') {
|
||||||
|
return unsupported('Factory Droid does not support Copilot profiles.');
|
||||||
|
}
|
||||||
|
return { supported: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.profileType === 'account') {
|
||||||
|
return unsupported(
|
||||||
|
'Codex CLI does not support Claude account-based profiles.',
|
||||||
|
'Use native Codex auth with: ccs --target codex'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.profileType === 'copilot') {
|
||||||
|
return unsupported('Codex CLI does not support Copilot profiles.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.profileType === 'default') {
|
||||||
|
return { supported: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.profileType === 'cliproxy') {
|
||||||
|
if (input.isComposite) {
|
||||||
|
return unsupported(
|
||||||
|
'Codex CLI currently does not support composite CLIProxy variants.',
|
||||||
|
'Use a Codex-only CLIProxy profile or stay on Claude/Droid for composite variants.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.cliproxyProvider !== 'codex') {
|
||||||
|
return unsupported(
|
||||||
|
`Codex CLI only supports CLIProxy provider "codex". This profile routes to "${input.cliproxyProvider || 'unknown'}".`,
|
||||||
|
'Use: ccs codex --target codex, ccs-codex codex, or stay on Claude/Droid for other providers.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { supported: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.profileType === 'settings') {
|
||||||
|
if (input.cliproxyBridgeProvider === 'codex') {
|
||||||
|
return { supported: true };
|
||||||
|
}
|
||||||
|
if (input.cliproxyBridgeProvider) {
|
||||||
|
return unsupported(
|
||||||
|
`Codex CLI only supports CLIProxy Codex bridge profiles. This API profile bridges "${input.cliproxyBridgeProvider}".`,
|
||||||
|
'Create a Codex bridge with: ccs api create --cliproxy-provider codex'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return unsupported(
|
||||||
|
'Codex CLI currently supports native default sessions and Codex-routed CLIProxy sessions only.',
|
||||||
|
'Use Claude/Droid for generic API profiles, or create a Codex bridge with: ccs api create --cliproxy-provider codex'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unsupported('Unsupported Codex runtime combination.');
|
||||||
|
}
|
||||||
@@ -131,6 +131,8 @@ describe('cross-platform', () => {
|
|||||||
assert(packageJson.bin.ccs, 'bin field should specify ccs command');
|
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['ccs-droid'], 'bin field should specify ccs-droid command');
|
||||||
assert(packageJson.bin.ccsd, 'bin field should specify ccsd 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(
|
assert.notStrictEqual(
|
||||||
packageJson.bin['ccs-droid'],
|
packageJson.bin['ccs-droid'],
|
||||||
packageJson.bin.ccs,
|
packageJson.bin.ccs,
|
||||||
@@ -141,10 +143,24 @@ describe('cross-platform', () => {
|
|||||||
packageJson.bin.ccsd,
|
packageJson.bin.ccsd,
|
||||||
'legacy ccsd alias should share the dedicated droid runtime entrypoint'
|
'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(
|
assert(
|
||||||
fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])),
|
fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])),
|
||||||
'dedicated droid runtime entrypoint should exist'
|
'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');
|
assert(packageJson.scripts, 'package.json should have scripts field');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,13 +71,35 @@ describe('ccsd alias integration', () => {
|
|||||||
expect(path.basename(argvPath)).toBe('ccs-droid');
|
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', () => {
|
it('should preserve extension-style alias basenames for wrapper compatibility', () => {
|
||||||
const cmdArgvPath = probeArgvPathDirect('ccsd.cmd');
|
const cmdArgvPath = probeArgvPathDirect('ccsd.cmd');
|
||||||
const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1');
|
const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1');
|
||||||
const explicitCmdArgvPath = probeArgvPathDirect('ccs-droid.cmd');
|
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(cmdArgvPath)).toBe('ccsd.cmd');
|
||||||
expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1');
|
expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1');
|
||||||
expect(path.basename(explicitCmdArgvPath)).toBe('ccs-droid.cmd');
|
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,140 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { CodexAdapter } from '../../../src/targets/codex-adapter';
|
||||||
|
|
||||||
|
describe('CodexAdapter', () => {
|
||||||
|
const adapter = new CodexAdapter();
|
||||||
|
|
||||||
|
test('supports default, settings, and cliproxy profile types', () => {
|
||||||
|
expect(adapter.supportsProfileType('default')).toBe(true);
|
||||||
|
expect(adapter.supportsProfileType('settings')).toBe(true);
|
||||||
|
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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,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,
|
getRegisteredTargets,
|
||||||
ClaudeAdapter,
|
ClaudeAdapter,
|
||||||
DroidAdapter,
|
DroidAdapter,
|
||||||
|
CodexAdapter,
|
||||||
} from '../../../src/targets';
|
} from '../../../src/targets';
|
||||||
|
|
||||||
describe('target-registry', () => {
|
describe('target-registry', () => {
|
||||||
@@ -20,6 +21,7 @@ describe('target-registry', () => {
|
|||||||
// Re-register adapters (registry is module-scoped singleton)
|
// Re-register adapters (registry is module-scoped singleton)
|
||||||
registerTarget(new ClaudeAdapter());
|
registerTarget(new ClaudeAdapter());
|
||||||
registerTarget(new DroidAdapter());
|
registerTarget(new DroidAdapter());
|
||||||
|
registerTarget(new CodexAdapter());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should register and retrieve claude adapter', () => {
|
it('should register and retrieve claude adapter', () => {
|
||||||
@@ -39,6 +41,12 @@ describe('target-registry', () => {
|
|||||||
expect(adapter.type).toBe('claude');
|
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', () => {
|
it('should throw for unknown target', () => {
|
||||||
expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/);
|
expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/);
|
||||||
});
|
});
|
||||||
@@ -46,6 +54,7 @@ describe('target-registry', () => {
|
|||||||
it('should check target existence', () => {
|
it('should check target existence', () => {
|
||||||
expect(hasTarget('claude')).toBe(true);
|
expect(hasTarget('claude')).toBe(true);
|
||||||
expect(hasTarget('droid')).toBe(true);
|
expect(hasTarget('droid')).toBe(true);
|
||||||
|
expect(hasTarget('codex')).toBe(true);
|
||||||
expect(hasTarget('unknown' as never)).toBe(false);
|
expect(hasTarget('unknown' as never)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,6 +62,7 @@ describe('target-registry', () => {
|
|||||||
const targets = getRegisteredTargets();
|
const targets = getRegisteredTargets();
|
||||||
expect(targets).toContain('claude');
|
expect(targets).toContain('claude');
|
||||||
expect(targets).toContain('droid');
|
expect(targets).toContain('droid');
|
||||||
|
expect(targets).toContain('codex');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target-
|
|||||||
describe('resolveTargetType', () => {
|
describe('resolveTargetType', () => {
|
||||||
const originalArgv = process.argv;
|
const originalArgv = process.argv;
|
||||||
const originalDroidAliases = process.env.CCS_DROID_ALIASES;
|
const originalDroidAliases = process.env.CCS_DROID_ALIASES;
|
||||||
|
const originalCodexAliases = process.env.CCS_CODEX_ALIASES;
|
||||||
const originalTargetAliases = process.env.CCS_TARGET_ALIASES;
|
const originalTargetAliases = process.env.CCS_TARGET_ALIASES;
|
||||||
const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
|
const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET;
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ describe('resolveTargetType', () => {
|
|||||||
process.env.CCS_DROID_ALIASES = originalDroidAliases;
|
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) {
|
if (originalTargetAliases === undefined) {
|
||||||
delete process.env.CCS_TARGET_ALIASES;
|
delete process.env.CCS_TARGET_ALIASES;
|
||||||
} else {
|
} else {
|
||||||
@@ -41,6 +48,11 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType(['--target', 'droid'])).toBe('droid');
|
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', () => {
|
it('should detect --target claude', () => {
|
||||||
process.argv = ['node', 'ccs'];
|
process.argv = ['node', 'ccs'];
|
||||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||||
@@ -56,6 +68,11 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude');
|
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', () => {
|
it('should prioritize --target flag over profile config', () => {
|
||||||
process.argv = ['node', 'ccs'];
|
process.argv = ['node', 'ccs'];
|
||||||
expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude');
|
expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude');
|
||||||
@@ -71,15 +88,31 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType([])).toBe('droid');
|
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', () => {
|
it('should detect custom target aliases from CCS_TARGET_ALIASES', () => {
|
||||||
process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid';
|
process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid';
|
||||||
process.argv = ['node', 'my-droid'];
|
process.argv = ['node', 'my-droid'];
|
||||||
expect(resolveTargetType([])).toBe('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', () => {
|
it('should ignore unsupported targets in CCS_TARGET_ALIASES', () => {
|
||||||
process.env.CCS_TARGET_ALIASES = 'codex=ccsx;droid=ccs-droid-custom';
|
process.env.CCS_TARGET_ALIASES = 'not-a-target=mystery-codex;droid=ccs-droid-custom';
|
||||||
process.argv = ['node', 'ccsx'];
|
process.argv = ['node', 'mystery-codex'];
|
||||||
expect(resolveTargetType([])).toBe('claude');
|
expect(resolveTargetType([])).toBe('claude');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +122,12 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType([])).toBe('droid');
|
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', () => {
|
it('should merge CCS_TARGET_ALIASES and CCS_DROID_ALIASES', () => {
|
||||||
process.env.CCS_TARGET_ALIASES = 'droid=team-droid';
|
process.env.CCS_TARGET_ALIASES = 'droid=team-droid';
|
||||||
process.env.CCS_DROID_ALIASES = 'legacy-droid';
|
process.env.CCS_DROID_ALIASES = 'legacy-droid';
|
||||||
@@ -100,6 +139,17 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType([])).toBe('droid');
|
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', () => {
|
it('should ignore invalid custom alias entries', () => {
|
||||||
process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,';
|
process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,';
|
||||||
process.argv = ['node', '../bad'];
|
process.argv = ['node', '../bad'];
|
||||||
@@ -112,6 +162,12 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType([])).toBe('droid');
|
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', () => {
|
it('should normalize argv[0] and custom aliases case-insensitively', () => {
|
||||||
process.env.CCS_DROID_ALIASES = 'DroidCaps';
|
process.env.CCS_DROID_ALIASES = 'DroidCaps';
|
||||||
process.argv = ['node', 'DROIDCAPS'];
|
process.argv = ['node', 'DROIDCAPS'];
|
||||||
@@ -128,11 +184,21 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType([])).toBe('droid');
|
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]', () => {
|
it('should strip .bat extension on Windows argv[0]', () => {
|
||||||
process.argv = ['node', 'ccsd.bat'];
|
process.argv = ['node', 'ccsd.bat'];
|
||||||
expect(resolveTargetType([])).toBe('droid');
|
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]', () => {
|
it('should strip .ps1 extension on Windows argv[0]', () => {
|
||||||
process.argv = ['node', 'ccsd.ps1'];
|
process.argv = ['node', 'ccsd.ps1'];
|
||||||
expect(resolveTargetType([])).toBe('droid');
|
expect(resolveTargetType([])).toBe('droid');
|
||||||
@@ -153,6 +219,11 @@ describe('resolveTargetType', () => {
|
|||||||
expect(resolveTargetType([])).toBe('droid');
|
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]', () => {
|
it('should prioritize --target over argv[0]', () => {
|
||||||
process.argv = ['node', 'ccsd'];
|
process.argv = ['node', 'ccsd'];
|
||||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||||
@@ -176,8 +247,10 @@ describe('resolveTargetType', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should keep reserved command names authoritative', () => {
|
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_DROID_ALIASES = 'ccs,ccs-droid,ccsd,legacy-droid';
|
||||||
|
process.env.CCS_CODEX_ALIASES = 'ccs,ccs-codex,ccsx,legacy-codex';
|
||||||
|
|
||||||
process.argv = ['node', 'ccs'];
|
process.argv = ['node', 'ccs'];
|
||||||
expect(resolveTargetType([])).toBe('claude');
|
expect(resolveTargetType([])).toBe('claude');
|
||||||
@@ -188,11 +261,23 @@ describe('resolveTargetType', () => {
|
|||||||
process.argv = ['node', 'ccsd'];
|
process.argv = ['node', 'ccsd'];
|
||||||
expect(resolveTargetType([])).toBe('droid');
|
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'];
|
process.argv = ['node', 'mydroid'];
|
||||||
expect(resolveTargetType([])).toBe('droid');
|
expect(resolveTargetType([])).toBe('droid');
|
||||||
|
|
||||||
process.argv = ['node', 'legacy-droid'];
|
process.argv = ['node', 'legacy-droid'];
|
||||||
expect(resolveTargetType([])).toBe('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', () => {
|
it('should throw for invalid --target value', () => {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user