fix(persist): block codex claude settings bridge

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-19 07:32:04 -04:00
committed by GitHub
parent a6ee3536c4
commit 3b2016462a
8 changed files with 219 additions and 27 deletions
+10
View File
@@ -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
+2 -1
View File
@@ -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.
+5 -2
View File
@@ -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.'
+18 -1
View File
@@ -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');
+30 -1
View File
@@ -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);