mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix(codex): self-heal ccsxp cliproxy provider (#1243)
This commit is contained in:
@@ -273,7 +273,8 @@ src/
|
||||
### Native Codex Runtime Target
|
||||
|
||||
- Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`.
|
||||
- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override.
|
||||
- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process.
|
||||
- Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance.
|
||||
- `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime.
|
||||
- Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`.
|
||||
- Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex.
|
||||
|
||||
@@ -521,6 +521,9 @@ ccsxp
|
||||
→ CCS_INTERNAL_ENTRY_TARGET=codex
|
||||
→ injects native `model_provider="cliproxy"` override
|
||||
→ pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set
|
||||
→ repairs `[model_providers.cliproxy]` in the active Codex `config.toml`
|
||||
→ injects the effective CCS CLIProxy auth token into the provider's configured `env_key`
|
||||
→ ignores the configured CCS default account/profile and stays in native Codex default mode
|
||||
```
|
||||
|
||||
If a user launches CCS through a custom shim instead of the built-in package bins, target
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { CCSXP_CLIPROXY_SHORTCUT_ENV } = require('../targets/codex-cliproxy-provider-config');
|
||||
const { stripTargetFlag } = require('../targets/target-resolver');
|
||||
const { expandPath } = require('../utils/helpers');
|
||||
const { fail } = require('../utils/ui');
|
||||
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1';
|
||||
const CCSXP_CLIPROXY_OVERRIDE = 'model_provider="cliproxy"';
|
||||
const DISALLOWED_CCSXP_CONFIG_KEY_REGEX =
|
||||
/^(model_provider|local_provider|profile)\s*=|^model_providers\./i;
|
||||
|
||||
@@ -252,6 +252,65 @@ describe('resolveProfileAndTarget', () => {
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps explicit codex cliproxy profile on the Codex target', async () => {
|
||||
const mockCodexAdapter = {
|
||||
displayName: 'Codex CLI',
|
||||
detectBinary: mock(() => ({ path: '/usr/local/bin/codex', version: '1.0.0' })),
|
||||
supportsProfileType: mock(() => true),
|
||||
};
|
||||
mockGetTarget.mockImplementation((_name: string) => mockCodexAdapter);
|
||||
mockResolveTargetType.mockImplementation((_args: string[]) => 'codex' as const);
|
||||
mockStripTargetFlag.mockImplementation((args: string[]) => {
|
||||
const next: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === '--target') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
next.push(args[index]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
mockDetectProfile.mockImplementation((args: string[]) => ({
|
||||
profile: args[0] || 'default',
|
||||
remainingArgs: args.slice(1),
|
||||
}));
|
||||
mockDetectProfileType.mockImplementation((profile: string) =>
|
||||
profile === 'codex'
|
||||
? {
|
||||
type: 'cliproxy' as const,
|
||||
name: 'codex',
|
||||
provider: 'codex',
|
||||
target: undefined,
|
||||
}
|
||||
: {
|
||||
type: 'account' as const,
|
||||
name: 'work',
|
||||
target: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await resolveProfileAndTarget({
|
||||
args: ['codex', '--target', 'codex', 'fix failing tests'],
|
||||
browserLaunchOverride: undefined,
|
||||
cliLogger: stubLogger,
|
||||
});
|
||||
|
||||
expect(result.profile).toBe('codex');
|
||||
expect(result.profileInfo.type).toBe('cliproxy');
|
||||
expect(result.resolvedTarget).toBe('codex');
|
||||
expect(result.targetAdapter).toBe(mockCodexAdapter);
|
||||
expect(result.targetRemainingArgs).toEqual(['fix failing tests']);
|
||||
expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: 'codex',
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: 'codex',
|
||||
})
|
||||
);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves settings profile (glm) with --target droid and loads settings', async () => {
|
||||
// Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude').
|
||||
const mockDroidAdapter = {
|
||||
|
||||
@@ -79,6 +79,18 @@ export interface ResolvedProfile {
|
||||
detector: InstanceType<typeof ProfileDetector>;
|
||||
}
|
||||
|
||||
function usesImplicitDefaultProfile(cleanArgs: string[]): boolean {
|
||||
return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true;
|
||||
}
|
||||
|
||||
function buildNativeCodexDefaultProfile(): ProfileDetectionResult {
|
||||
return {
|
||||
type: 'default',
|
||||
name: 'default',
|
||||
message: 'Using native Codex auth; CCS default profile is not applied to Codex target.',
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Profile and Target Resolver ==========
|
||||
|
||||
/**
|
||||
@@ -112,7 +124,7 @@ export async function resolveProfileAndTarget(
|
||||
// Detect profile (strip --target flags before profile detection)
|
||||
const cleanArgs = stripTargetFlag(args);
|
||||
const { profile, remainingArgs } = detectProfile(cleanArgs);
|
||||
const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
|
||||
let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
|
||||
|
||||
let resolvedTarget: ReturnType<typeof resolveTargetType>;
|
||||
try {
|
||||
@@ -127,6 +139,10 @@ export async function resolveProfileAndTarget(
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resolvedTarget === 'codex' && usesImplicitDefaultProfile(cleanArgs)) {
|
||||
profileInfo = buildNativeCodexDefaultProfile();
|
||||
}
|
||||
|
||||
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
|
||||
const claudeCliRaw = detectClaudeCli();
|
||||
if (resolvedTarget === 'claude' && !claudeCliRaw) {
|
||||
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
readCodexVersion,
|
||||
} from './codex-detector';
|
||||
import { createLogger } from '../services/logging';
|
||||
import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager';
|
||||
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
|
||||
import {
|
||||
CCSXP_CLIPROXY_SHORTCUT_ENV,
|
||||
CODEX_CLIPROXY_PROVIDER_ENV_KEY,
|
||||
ensureCodexCliproxyProviderConfig,
|
||||
isCcsxpCliproxyShortcut,
|
||||
} from './codex-cliproxy-provider-config';
|
||||
|
||||
const adapterLogger = createLogger('targets:codex');
|
||||
|
||||
@@ -176,13 +184,25 @@ function prepareExplicitCodexHome(
|
||||
export class CodexAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'codex';
|
||||
readonly displayName = 'Codex CLI';
|
||||
private ccsxpCliproxyEnvKey = CODEX_CLIPROXY_PROVIDER_ENV_KEY;
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
return getCodexBinaryInfo({ includeVersion: false, includeFeatures: false });
|
||||
}
|
||||
|
||||
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
|
||||
// Codex uses transient -c overrides plus env_key injection.
|
||||
if (!isCcsxpCliproxyShortcut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const providerRepair = await ensureCodexCliproxyProviderConfig(resolveLifecyclePort());
|
||||
this.ccsxpCliproxyEnvKey = providerRepair.envKey;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`ccsxp could not repair the native Codex cliproxy provider: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
buildArgs(
|
||||
@@ -258,7 +278,11 @@ export class CodexAdapter implements TargetAdapter {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))),
|
||||
};
|
||||
delete env[CCSXP_CLIPROXY_SHORTCUT_ENV];
|
||||
delete env[CODEX_RUNTIME_ENV_KEY];
|
||||
if (profileType === 'default' && isCcsxpCliproxyShortcut()) {
|
||||
env[this.ccsxpCliproxyEnvKey || CODEX_CLIPROXY_PROVIDER_ENV_KEY] = getEffectiveApiKey();
|
||||
}
|
||||
if (profileType !== 'default') {
|
||||
if (!creds.apiKey?.trim()) {
|
||||
throw new Error('Codex target requires an API key for CCS-backed profile launches.');
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import {
|
||||
probeTomlObjectFile,
|
||||
stringifyTomlObject,
|
||||
writeTomlFileAtomic,
|
||||
} from '../web-server/services/compatible-cli-toml-file-service';
|
||||
|
||||
export const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT';
|
||||
export const CODEX_CLIPROXY_PROVIDER_ID = 'cliproxy';
|
||||
export const CODEX_CLIPROXY_PROVIDER_ENV_KEY = 'CLIPROXY_API_KEY';
|
||||
export const CODEX_CLIPROXY_PROVIDER_NAME = 'CLIProxy Codex';
|
||||
|
||||
export interface CodexCliproxyProviderRepairResult {
|
||||
changed: boolean;
|
||||
configPath: string;
|
||||
displayPath: string;
|
||||
envKey: string;
|
||||
}
|
||||
|
||||
function resolveCodexConfigPath(env: NodeJS.ProcessEnv = process.env): {
|
||||
configPath: string;
|
||||
displayPath: string;
|
||||
} {
|
||||
const baseDir = path.resolve(
|
||||
env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(os.homedir(), '.codex')
|
||||
);
|
||||
const displayBase = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex';
|
||||
return {
|
||||
configPath: path.join(baseDir, 'config.toml'),
|
||||
displayPath: `${displayBase}/config.toml`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCodexCliproxyProviderBaseUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}/api/provider/codex`;
|
||||
}
|
||||
|
||||
export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env[CCSXP_CLIPROXY_SHORTCUT_ENV] === '1';
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeLocalProviderUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (
|
||||
(url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
|
||||
url.pathname === '/api/provider/codex'
|
||||
) {
|
||||
url.hostname = '127.0.0.1';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProviderEnvKey(provider: Record<string, unknown> | null): string {
|
||||
const envKey = provider?.env_key;
|
||||
if (typeof envKey === 'string' && envKey.trim()) {
|
||||
return envKey.trim();
|
||||
}
|
||||
return CODEX_CLIPROXY_PROVIDER_ENV_KEY;
|
||||
}
|
||||
|
||||
function isProviderReady(
|
||||
provider: Record<string, unknown>,
|
||||
expectedBaseUrl: string,
|
||||
envKey: string
|
||||
): boolean {
|
||||
return (
|
||||
provider.name === CODEX_CLIPROXY_PROVIDER_NAME &&
|
||||
normalizeLocalProviderUrl(provider.base_url) === expectedBaseUrl &&
|
||||
provider.env_key === envKey &&
|
||||
provider.wire_api === 'responses' &&
|
||||
provider.requires_openai_auth === false &&
|
||||
provider.supports_websockets === false
|
||||
);
|
||||
}
|
||||
|
||||
function buildProviderConfig(baseUrl: string, envKey: string): Record<string, unknown> {
|
||||
return {
|
||||
name: CODEX_CLIPROXY_PROVIDER_NAME,
|
||||
base_url: baseUrl,
|
||||
env_key: envKey,
|
||||
wire_api: 'responses',
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderBlock(baseUrl: string): string {
|
||||
return stringifyTomlObject({
|
||||
model_providers: {
|
||||
[CODEX_CLIPROXY_PROVIDER_ID]: buildProviderConfig(baseUrl, CODEX_CLIPROXY_PROVIDER_ENV_KEY),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function appendProviderBlock(rawText: string, baseUrl: string): string {
|
||||
const prefix = rawText.trimEnd();
|
||||
const providerBlock = buildProviderBlock(baseUrl).trimEnd();
|
||||
return prefix ? `${prefix}\n\n${providerBlock}\n` : `${providerBlock}\n`;
|
||||
}
|
||||
|
||||
export async function ensureCodexCliproxyProviderConfig(
|
||||
port: number,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<CodexCliproxyProviderRepairResult> {
|
||||
const { configPath, displayPath } = resolveCodexConfigPath(env);
|
||||
const fileProbe = await probeTomlObjectFile(configPath, 'Codex user config', displayPath);
|
||||
|
||||
if (fileProbe.diagnostics.readError) {
|
||||
throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`);
|
||||
}
|
||||
if (fileProbe.diagnostics.parseError) {
|
||||
throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`);
|
||||
}
|
||||
|
||||
const config = fileProbe.config ?? {};
|
||||
const modelProvidersValue = config.model_providers;
|
||||
const providers = asObject(modelProvidersValue);
|
||||
const expectedBaseUrl = buildCodexCliproxyProviderBaseUrl(port);
|
||||
|
||||
if (modelProvidersValue !== undefined && !providers) {
|
||||
throw new Error(`Cannot repair ${displayPath}: [model_providers] must be a table.`);
|
||||
}
|
||||
|
||||
if (!providers || !Object.prototype.hasOwnProperty.call(providers, CODEX_CLIPROXY_PROVIDER_ID)) {
|
||||
await writeTomlFileAtomic({
|
||||
filePath: configPath,
|
||||
rawText: appendProviderBlock(fileProbe.rawText, expectedBaseUrl),
|
||||
expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined,
|
||||
fileLabel: 'config.toml',
|
||||
});
|
||||
return { changed: true, configPath, displayPath, envKey: CODEX_CLIPROXY_PROVIDER_ENV_KEY };
|
||||
}
|
||||
|
||||
const currentProvider = asObject(providers[CODEX_CLIPROXY_PROVIDER_ID]);
|
||||
if (!currentProvider) {
|
||||
throw new Error(
|
||||
`Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.`
|
||||
);
|
||||
}
|
||||
|
||||
const envKey = resolveProviderEnvKey(currentProvider);
|
||||
|
||||
if (isProviderReady(currentProvider, expectedBaseUrl, envKey)) {
|
||||
return { changed: false, configPath, displayPath, envKey };
|
||||
}
|
||||
|
||||
providers[CODEX_CLIPROXY_PROVIDER_ID] = {
|
||||
...currentProvider,
|
||||
...buildProviderConfig(expectedBaseUrl, envKey),
|
||||
};
|
||||
|
||||
await writeTomlFileAtomic({
|
||||
filePath: configPath,
|
||||
rawText: stringifyTomlObject(config),
|
||||
expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined,
|
||||
fileLabel: 'config.toml',
|
||||
});
|
||||
return { changed: true, configPath, displayPath, envKey };
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function evaluateTargetRuntimeCompatibility(
|
||||
if (input.profileType === 'account') {
|
||||
return unsupported(
|
||||
'Codex CLI does not support Claude account-based profiles.',
|
||||
'Use native Codex auth with: ccs --target codex'
|
||||
'Native Codex: ccs --target codex. CLIProxy Codex pool: ccs codex --target codex or ccsxp. Manage pool/routing with: ccs codex --auth and ccs cliproxy routing.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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 {
|
||||
buildCodexCliproxyProviderBaseUrl,
|
||||
ensureCodexCliproxyProviderConfig,
|
||||
} from '../../../src/targets/codex-cliproxy-provider-config';
|
||||
|
||||
describe('codex cliproxy provider config repair', () => {
|
||||
let tempHome: string;
|
||||
let codexHome: string;
|
||||
let configPath: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-provider-config-'));
|
||||
codexHome = path.join(tempHome, '.codex');
|
||||
configPath = path.join(codexHome, 'config.toml');
|
||||
env = { CODEX_HOME: codexHome } as NodeJS.ProcessEnv;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the cliproxy model provider when config.toml is missing', async () => {
|
||||
const result = await ensureCodexCliproxyProviderConfig(8317, env);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CLIPROXY_API_KEY');
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain('[model_providers.cliproxy]');
|
||||
expect(rawText).toContain('name = "CLIProxy Codex"');
|
||||
expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"');
|
||||
expect(rawText).not.toContain('model_provider = "cliproxy"');
|
||||
});
|
||||
|
||||
it('appends the missing provider while preserving existing raw config text', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(configPath, '# user note\nmodel = "gpt-5.4"\n', 'utf8');
|
||||
|
||||
const result = await ensureCodexCliproxyProviderConfig(8317, env);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CLIPROXY_API_KEY');
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText.startsWith('# user note\nmodel = "gpt-5.4"\n\n')).toBe(true);
|
||||
expect(rawText).toContain('[model_providers.cliproxy]');
|
||||
});
|
||||
|
||||
it('repairs an existing incomplete cliproxy provider', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`[model_providers.cliproxy]
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
wire_api = "responses"
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await ensureCodexCliproxyProviderConfig(9321, env);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CLIPROXY_API_KEY');
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`);
|
||||
expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"');
|
||||
expect(rawText).toContain('requires_openai_auth = false');
|
||||
expect(rawText).toContain('supports_websockets = false');
|
||||
});
|
||||
|
||||
it('preserves a custom cliproxy provider env key while repairing other fields', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`[model_providers.cliproxy]
|
||||
name = "Old Name"
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"
|
||||
wire_api = "chat"
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await ensureCodexCliproxyProviderConfig(9321, env);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CCS_CUSTOM_CLIPROXY_TOKEN');
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`);
|
||||
expect(rawText).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"');
|
||||
expect(rawText).toContain('wire_api = "responses"');
|
||||
});
|
||||
|
||||
it('rejects invalid non-table model_providers values without appending broken TOML', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const rawText = 'model_providers = "legacy"\n';
|
||||
fs.writeFileSync(configPath, rawText, 'utf8');
|
||||
|
||||
await expect(ensureCodexCliproxyProviderConfig(8317, env)).rejects.toThrow(
|
||||
'[model_providers] must be a table'
|
||||
);
|
||||
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText);
|
||||
});
|
||||
|
||||
it('leaves a ready localhost provider unchanged', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const rawText = `[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
env_key = "CLIPROXY_API_KEY"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
`;
|
||||
fs.writeFileSync(configPath, rawText, 'utf8');
|
||||
|
||||
const result = await ensureCodexCliproxyProviderConfig(8317, env);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.envKey).toBe('CLIPROXY_API_KEY');
|
||||
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText);
|
||||
});
|
||||
});
|
||||
@@ -116,18 +116,32 @@ if (out) {
|
||||
}
|
||||
const envOut = process.env.CCS_TEST_CODEX_ENV_OUT;
|
||||
if (envOut) {
|
||||
const loggedEnv = {
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
CODEX_CI: process.env.CODEX_CI,
|
||||
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
|
||||
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR,
|
||||
CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL,
|
||||
};
|
||||
if (
|
||||
process.env.CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY === '1' &&
|
||||
process.env.CLIPROXY_API_KEY !== undefined
|
||||
) {
|
||||
loggedEnv.CLIPROXY_API_KEY = process.env.CLIPROXY_API_KEY;
|
||||
}
|
||||
const extraEnvKeys = (process.env.CCS_TEST_CODEX_LOG_ENV_KEYS || '')
|
||||
.split(',')
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean);
|
||||
for (const key of extraEnvKeys) {
|
||||
loggedEnv[key] = process.env[key];
|
||||
}
|
||||
fs.appendFileSync(
|
||||
envOut,
|
||||
JSON.stringify({
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
CODEX_CI: process.env.CODEX_CI,
|
||||
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
|
||||
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR,
|
||||
CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL,
|
||||
}) + '\\n'
|
||||
JSON.stringify(loggedEnv) + '\\n'
|
||||
);
|
||||
}
|
||||
const configFlagIndex = cliArgs.findIndex((arg) => arg === '-c' || arg === '--config');
|
||||
@@ -396,6 +410,7 @@ process.exit(0);
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
@@ -413,6 +428,7 @@ process.exit(0);
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
@@ -647,6 +663,7 @@ process.exit(0);
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
@@ -668,21 +685,24 @@ process.exit(0);
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
|
||||
CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1',
|
||||
CODEX_HOME: inheritedCodexHome,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: path.join(os.homedir(), '.codex'),
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
@@ -697,10 +717,112 @@ process.exit(0);
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
]);
|
||||
const codexConfig = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain('[model_providers.cliproxy]');
|
||||
expect(codexConfig).toContain('env_key = "CLIPROXY_API_KEY"');
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads the configured cliproxy provider env_key for ccsxp launches', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const codexHome = path.join(tmpHome, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
`[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runCcsxpAlias(['fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_TEST_CODEX_LOG_ENV_KEYS: 'CCS_CUSTOM_CLIPROXY_TOKEN',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
]);
|
||||
const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"');
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: codexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps ccsxp native when the CCS default profile is a Claude account', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 2',
|
||||
'default: work',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-01-01"',
|
||||
' last_used: "2026-01-01"',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = runCcsxpAlias(['fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
@@ -709,7 +831,51 @@ process.exit(0);
|
||||
]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: path.join(os.homedir(), '.codex'),
|
||||
CODEX_HOME: path.join(tmpHome, '.codex'),
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps implicit ccs --target codex launches native when the CCS default is a Claude account', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 2',
|
||||
'default: work',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-01-01"',
|
||||
' last_used: "2026-01-01"',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = runCcs(['--target', 'codex'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_THINKING: '8192',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([[]]);
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: undefined,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
@@ -721,6 +887,35 @@ process.exit(0);
|
||||
]);
|
||||
});
|
||||
|
||||
it('still rejects an explicit Claude account profile on the Codex target', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 2',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-01-01"',
|
||||
' last_used: "2026-01-01"',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = runCcs(['work', '--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,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Codex CLI does not support Claude account-based profiles.');
|
||||
expect(result.stderr).toContain('CLIProxy Codex pool: ccs codex --target codex or ccsxp');
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects conflicting native provider config overrides for ccsxp', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
@@ -728,6 +923,7 @@ process.exit(0);
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
@@ -745,6 +941,7 @@ process.exit(0);
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
@@ -767,6 +964,7 @@ process.exit(0);
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
|
||||
CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1',
|
||||
CODEX_HOME: path.join(tmpHome, 'inherited-codex-home'),
|
||||
CCSXP_CODEX_HOME: explicitCodexHome,
|
||||
});
|
||||
@@ -779,6 +977,7 @@ process.exit(0);
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CLIPROXY_API_KEY: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
|
||||
@@ -87,16 +87,21 @@ describe('evaluateTargetRuntimeCompatibility', () => {
|
||||
profileType: 'settings',
|
||||
});
|
||||
expect(genericSettingsCompatibility.supported).toBe(false);
|
||||
expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/);
|
||||
expect(genericSettingsCompatibility.reason).toMatch(
|
||||
/currently supports native default sessions/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects account, copilot, and cursor profiles on Codex target', () => {
|
||||
expect(
|
||||
evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'account',
|
||||
}).supported
|
||||
).toBe(false);
|
||||
const accountCompatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
profileType: 'account',
|
||||
});
|
||||
expect(accountCompatibility.supported).toBe(false);
|
||||
expect(accountCompatibility.suggestion).toContain('ccs --target codex');
|
||||
expect(accountCompatibility.suggestion).toContain('ccs codex --target codex');
|
||||
expect(accountCompatibility.suggestion).toContain('ccs cliproxy routing');
|
||||
|
||||
expect(
|
||||
evaluateTargetRuntimeCompatibility({
|
||||
target: 'codex',
|
||||
|
||||
@@ -58,9 +58,12 @@ export interface CodexFeatureCatalogEntry {
|
||||
export const CLIPROXY_NATIVE_CODEX_RECIPE = `model_provider = "cliproxy"
|
||||
|
||||
[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "http://127.0.0.1:8317/api/provider/codex"
|
||||
env_key = "CLIPROXY_API_KEY"
|
||||
wire_api = "responses"`;
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false`;
|
||||
|
||||
export const KNOWN_CODEX_FEATURE_NAMES = [
|
||||
'multi_agent',
|
||||
|
||||
Reference in New Issue
Block a user