mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(persist): block codex claude settings bridge
This commit is contained in:
@@ -66,6 +66,16 @@ codex # runs with CODEX_HOME=~/.ccs/codex-instances/pers
|
||||
Native `codex` shells only see the persistent default when launched through the `ccsx`
|
||||
Codex runtime. For an already-open shell or a plain native `codex` binary, use `auth use`.
|
||||
|
||||
Do not use `ccs persist codex` for Claude Code or the Claude Code Extension. That path
|
||||
would persist Claude settings that send Claude traffic through the Codex translator. CCS
|
||||
blocks Codex CLIProxy profiles from Claude extension setup; use `ccsxp` or
|
||||
`ccs codex --target codex` for ChatGPT/Codex subscriptions. If old settings were already
|
||||
persisted, clear them with:
|
||||
|
||||
```bash
|
||||
ccs persist default --yes
|
||||
```
|
||||
|
||||
Shell syntax for `use`:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-05-18
|
||||
Last Updated: 2026-05-19
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-05-19**: **#1252** Claude extension persistence now rejects Codex CLIProxy profiles instead of writing `ANTHROPIC_BASE_URL=/api/provider/codex` into Claude Code settings. Native Codex subscription use stays on `ccsxp` or `ccs codex --target codex`, and `ccs doctor` warns when stale Claude settings still point at the Codex translator.
|
||||
- **2026-05-18**: **#1287** `ccsx resume` now passes through to the native Codex `resume` subcommand before CCS profile detection, preserving Codex session continuation while keeping `ccsx auth` on the managed Codex profile namespace.
|
||||
- **2026-05-09**: **#1199** Existing Claude auth accounts now have dashboard-visible Shared Resources controls separate from History Sync. Accounts exposes a dedicated Resources action for `shared` vs `profile-local`, `/shared` now inventories commands, skills, agents, plugins, and `settings.json`, plugin directories without docs show factual directory contents, and shared settings content is inspectable read-only through the localhost-gated shared-content API.
|
||||
- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing.
|
||||
|
||||
@@ -680,7 +680,7 @@ async function showHelp(): Promise<void> {
|
||||
console.log('');
|
||||
console.log(subheader('Supported Profile Types'));
|
||||
console.log(` ${color('API profiles', 'command')} glm, km, custom API profiles`);
|
||||
console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`);
|
||||
console.log(` ${color('CLIProxy', 'command')} gemini, agy, qwen, kiro, ghcp`);
|
||||
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
|
||||
console.log(
|
||||
` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)`
|
||||
@@ -700,7 +700,7 @@ async function showHelp(): Promise<void> {
|
||||
console.log(` ${color('ccs persist glm --permission-mode acceptEdits', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist with auto-approve enabled')}`);
|
||||
console.log(` ${color('ccs persist codex --dangerously-skip-permissions', 'command')}`);
|
||||
console.log(` ${color('ccs persist glm --dangerously-skip-permissions', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`);
|
||||
console.log(` ${color('ccs persist work --yes', 'command')}`);
|
||||
@@ -719,6 +719,9 @@ async function showHelp(): Promise<void> {
|
||||
console.log('');
|
||||
console.log(subheader('Notes'));
|
||||
console.log(' [i] CLIProxy profiles require the proxy to be running.');
|
||||
console.log(
|
||||
' [i] Codex CLIProxy profiles are native Codex-only: use ccsxp or ccs codex --target codex.'
|
||||
);
|
||||
console.log(' [i] Copilot profiles require copilot-api daemon.');
|
||||
console.log(
|
||||
' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.'
|
||||
|
||||
@@ -219,7 +219,24 @@ export class ClaudeSettingsChecker implements IHealthChecker {
|
||||
// Validate JSON
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf8');
|
||||
JSON.parse(content);
|
||||
const settings = JSON.parse(content) as {
|
||||
env?: Record<string, unknown>;
|
||||
};
|
||||
const baseUrl =
|
||||
settings.env && typeof settings.env.ANTHROPIC_BASE_URL === 'string'
|
||||
? settings.env.ANTHROPIC_BASE_URL
|
||||
: '';
|
||||
if (baseUrl.includes('/api/provider/codex')) {
|
||||
spinner.warn();
|
||||
console.log(` ${warn(settingsName.padEnd(22))} Codex CLIProxy bridge persisted`);
|
||||
results.addCheck(
|
||||
'Claude Settings',
|
||||
'warning',
|
||||
'Claude settings route Claude Code through the Codex CLIProxy translator',
|
||||
'Run: ccs persist default --yes; use ccsxp or ccs codex --target codex for Codex'
|
||||
);
|
||||
return;
|
||||
}
|
||||
spinner.succeed();
|
||||
console.log(` ${ok(settingsName.padEnd(22))} Valid`);
|
||||
results.addCheck('Claude Settings', 'success');
|
||||
|
||||
@@ -103,6 +103,30 @@ function describeProfile(profileName: string, result: ProfileDetectionResult): s
|
||||
return 'Native Claude profile resolution.';
|
||||
}
|
||||
|
||||
function resultUsesCodexProvider(result: ProfileDetectionResult): boolean {
|
||||
if (result.type !== 'cliproxy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.provider === 'codex') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!result.isComposite || !result.compositeTiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(result.compositeTiers).some(
|
||||
(tier) => tier.provider === 'codex' || tier.fallback?.provider === 'codex'
|
||||
);
|
||||
}
|
||||
|
||||
function createCodexClaudeExtensionError(profileName: string): Error {
|
||||
return new Error(
|
||||
`Profile "${profileName}" is a Codex CLIProxy profile. CCS does not persist Codex CLIProxy profiles into Claude Code or Claude Code Extension settings because that routes Claude traffic through the Codex translator. Use ccsxp or ccs codex --target codex for ChatGPT/Codex subscriptions. To clear stale Claude settings, run ccs persist default --yes.`
|
||||
);
|
||||
}
|
||||
|
||||
function createProfileOption(
|
||||
profileName: string,
|
||||
result: ProfileDetectionResult
|
||||
@@ -140,7 +164,9 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] {
|
||||
}
|
||||
|
||||
return deduped
|
||||
.map((profileName) => createProfileOption(profileName, detector.detectProfileType(profileName)))
|
||||
.map((profileName) => ({ profileName, result: detector.detectProfileType(profileName) }))
|
||||
.filter(({ result }) => !resultUsesCodexProvider(result))
|
||||
.map(({ profileName, result }) => createProfileOption(profileName, result))
|
||||
.sort((left, right) => deduped.indexOf(left.name) - deduped.indexOf(right.name));
|
||||
}
|
||||
|
||||
@@ -294,6 +320,9 @@ export async function resolveClaudeExtensionSetup(
|
||||
): Promise<ClaudeExtensionSetup> {
|
||||
const detector = new ProfileDetector();
|
||||
const result = detector.detectProfileType(requestedProfile);
|
||||
if (resultUsesCodexProvider(result)) {
|
||||
throw createCodexClaudeExtensionError(requestedProfile);
|
||||
}
|
||||
const resolved = await resolveExtensionEnv(requestedProfile, result);
|
||||
|
||||
return {
|
||||
|
||||
@@ -58,7 +58,11 @@ async function createRestoreFixture(
|
||||
};
|
||||
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(settingsPath, JSON.stringify(originalSettings, null, 2) + '\n', 'utf8');
|
||||
await fs.promises.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify(originalSettings, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
await fs.promises.writeFile(backupPath, JSON.stringify(backupSettings, null, 2) + '\n', 'utf8');
|
||||
|
||||
return { claudeDir, settingsPath, backupPath, timestamp, originalSettings, backupSettings };
|
||||
@@ -98,15 +102,15 @@ afterEach(async () => {
|
||||
|
||||
describe('persist command real handler paths', () => {
|
||||
it('throws parseError for missing --permission-mode before profile detection', async () => {
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode']))).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['glm', '--permission-mode']))
|
||||
).rejects.toThrow('Missing value for --permission-mode');
|
||||
});
|
||||
|
||||
it('throws parseError for empty inline --permission-mode before profile detection', async () => {
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))
|
||||
).rejects.toThrow('Missing value for --permission-mode');
|
||||
});
|
||||
|
||||
it('throws parseError for invalid --permission-mode before profile detection', async () => {
|
||||
@@ -116,34 +120,36 @@ describe('persist command real handler paths', () => {
|
||||
});
|
||||
|
||||
it('throws parseError for unknown flags on real handler path', async () => {
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--unknown-flag']))).rejects.toThrow(
|
||||
/Unknown option\(s\)/
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['glm', '--unknown-flag']))
|
||||
).rejects.toThrow(/Unknown option\(s\)/);
|
||||
});
|
||||
|
||||
it('throws parseError for list/restore conflict on real handler path', async () => {
|
||||
await expect(withScopedHome(() => handlePersistCommand(['--list-backups', '--restore']))).rejects.toThrow(
|
||||
'--list-backups cannot be used with --restore'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--list-backups', '--restore']))
|
||||
).rejects.toThrow('--list-backups cannot be used with --restore');
|
||||
});
|
||||
|
||||
it('throws parseError for permission flags with --restore on real handler path', async () => {
|
||||
await expect(withScopedHome(() => handlePersistCommand(['--restore', '--auto-approve']))).rejects.toThrow(
|
||||
/Permission flags are not valid with backup operations/
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--restore', '--auto-approve']))
|
||||
).rejects.toThrow(/Permission flags are not valid with backup operations/);
|
||||
});
|
||||
|
||||
it('shows help when --help is present even with other invalid args', async () => {
|
||||
await expect(withScopedHome(() => handlePersistCommand(['--help', '--permission-mode']))).resolves.toBeUndefined();
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['--help', '--permission-mode']))
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => {
|
||||
const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early');
|
||||
process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir;
|
||||
|
||||
await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow(
|
||||
'Missing value for --permission-mode'
|
||||
);
|
||||
await expect(
|
||||
withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))
|
||||
).rejects.toThrow('Missing value for --permission-mode');
|
||||
expect(await pathExists(isolatedClaudeDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -294,6 +300,10 @@ describe('persist command Claude extension parity', () => {
|
||||
type: 'api',
|
||||
settings: path.join(tempRoot, '.ccs', 'glm.settings.json'),
|
||||
};
|
||||
config.cliproxy.variants.codex = {
|
||||
provider: 'codex',
|
||||
settings: path.join(tempRoot, '.ccs', 'codex.settings.json'),
|
||||
};
|
||||
|
||||
await fs.promises.mkdir(path.join(tempRoot, '.ccs'), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
@@ -383,4 +393,50 @@ describe('persist command Claude extension parity', () => {
|
||||
expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work'));
|
||||
expect(fs.existsSync(persisted.env.CLAUDE_CONFIG_DIR)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks Codex CLIProxy profiles from Claude settings persistence', async () => {
|
||||
await writeUnifiedConfig();
|
||||
|
||||
const settingsPath = path.join(tempRoot, '.claude', 'settings.json');
|
||||
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
KEEP_ME: 'still-here',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
const capturedLogs: string[] = [];
|
||||
console.log = (...args: unknown[]) => {
|
||||
capturedLogs.push(args.map((arg) => String(arg)).join(' '));
|
||||
};
|
||||
|
||||
stubProcessExit();
|
||||
try {
|
||||
await expect(withScopedHome(() => handlePersistCommand(['codex', '--yes']))).rejects.toThrow(
|
||||
'process.exit(1)'
|
||||
);
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
|
||||
const renderedLogs = capturedLogs.join('\n');
|
||||
expect(renderedLogs).toContain('Codex CLIProxy profile');
|
||||
expect(renderedLogs).toContain('ccsxp');
|
||||
expect(renderedLogs).toContain('ccs persist default --yes');
|
||||
|
||||
const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.KEEP_ME).toBe('still-here');
|
||||
expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ClaudeSettingsChecker } from '../../../../src/management/checks/config-check';
|
||||
import { HealthCheck } from '../../../../src/management/checks/types';
|
||||
import { runWithScopedCcsHome } from '../../../../src/utils/config-manager';
|
||||
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
});
|
||||
|
||||
describe('ClaudeSettingsChecker', () => {
|
||||
it('warns when Claude settings point at the Codex CLIProxy translator', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-check-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
try {
|
||||
await runWithScopedCcsHome(tempRoot, async () => {
|
||||
const settingsPath = path.join(tempRoot, '.claude', 'settings.json');
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
const results = new HealthCheck();
|
||||
new ClaudeSettingsChecker().run(results);
|
||||
|
||||
expect(results.warnings).toHaveLength(1);
|
||||
expect(results.warnings[0].message).toContain('Codex CLIProxy translator');
|
||||
expect(results.warnings[0].fix).toContain('ccs persist default --yes');
|
||||
} finally {
|
||||
console.log = originalConsoleLog;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,8 +34,9 @@ describe('web-server claude-extension-routes', () => {
|
||||
({ default: SharedManager } = await import('../../../src/management/shared-manager'));
|
||||
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
|
||||
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
|
||||
({ default: claudeExtensionRoutes } =
|
||||
await import('../../../src/web-server/routes/claude-extension-routes'));
|
||||
({ default: claudeExtensionRoutes } = await import(
|
||||
'../../../src/web-server/routes/claude-extension-routes'
|
||||
));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -127,6 +128,10 @@ describe('web-server claude-extension-routes', () => {
|
||||
context_mode: 'isolated',
|
||||
};
|
||||
config.default = 'work';
|
||||
config.cliproxy.variants.codex = {
|
||||
provider: 'codex',
|
||||
settings: path.join(ccsDir, 'codex.settings.json'),
|
||||
};
|
||||
saveUnifiedConfig(config);
|
||||
});
|
||||
|
||||
@@ -154,12 +159,23 @@ describe('web-server claude-extension-routes', () => {
|
||||
expect(payload.profiles.some((profile) => profile.name === 'glm')).toBe(true);
|
||||
expect(payload.profiles.some((profile) => profile.name === 'work')).toBe(true);
|
||||
expect(payload.profiles.some((profile) => profile.name === 'gemini')).toBe(true);
|
||||
expect(payload.profiles.some((profile) => profile.name === 'codex')).toBe(false);
|
||||
expect(payload.hosts.map((host) => host.id)).toEqual(['vscode', 'cursor', 'windsurf']);
|
||||
expect(payload.hosts.every((host) => host.defaultSettingsPath.endsWith('settings.json'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects Codex CLIProxy setup with native Codex guidance', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=codex&host=vscode`);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('Codex CLIProxy profile');
|
||||
expect(payload.error).toContain('ccsxp');
|
||||
expect(payload.error).toContain('ccs codex --target codex');
|
||||
});
|
||||
|
||||
it('renders VS Code setup for API profiles with disableLoginPrompt', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user