From 05685d1c21bab786a20e5cdccceb326b12a0e091 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:45:09 -0400 Subject: [PATCH 01/35] fix: keep agy websearch permissions enabled (#1625) --- lib/hooks/websearch-transformer.cjs | 14 +++----------- tests/unit/hooks/websearch-transformer.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 39996352..ba1bb199 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -901,17 +901,9 @@ function tryAgySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const timeoutMs = timeoutSec * 1000; const model = process.env.CCS_WEBSEARCH_AGY_MODEL || PROVIDER_CONFIG.agy.model; const prompt = buildPrompt('agy', query); - // gemini flag mapping: `--yolo` -> `--dangerously-skip-permissions`, `-m` -> `--model`, - // shell `timeout N` -> native `--print-timeout Ns`. The prompt is passed to `-p` (print mode). - const args = [ - '--model', - model, - '--dangerously-skip-permissions', - '--print-timeout', - `${timeoutSec}s`, - '-p', - prompt, - ]; + // Use print mode without disabling Antigravity permission checks. WebSearch queries can + // contain untrusted prompt text, so permission prompts must remain enforced. + const args = ['--model', model, '--print-timeout', `${timeoutSec}s`, '-p', prompt]; debug(`Executing Antigravity (agy) fallback with model ${model}`); return runAgyCommand(args, timeoutMs); diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 822cd4a2..79730360 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -133,6 +133,16 @@ describe('websearch-transformer legacy CLI safety', () => { expect(source).not.toContain('shell: isWindows'); expect(source.match(/shell: false/g) || []).toHaveLength(4); }); + + it('does not disable Antigravity permission checks for query-derived prompts', () => { + const source = readFileSync(hookPath, 'utf8'); + + expect(source).not.toContain("'--dangerously-skip-permissions'"); + expect(source).not.toContain('"--dangerously-skip-permissions"'); + expect(source).toContain( + "const args = ['--model', model, '--print-timeout', `${timeoutSec}s`, '-p', prompt]" + ); + }); }); describe('websearch-transformer hook helpers', () => { From 3621f8dcb1454cc9c6d25669cdd46f227282fbe7 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:49:56 -0400 Subject: [PATCH 02/35] fix: validate CCS Bar launch shim target integrity (#1626) * fix: validate CCS Bar launch shim target integrity * fix: execute verified bar shim bytes --- src/commands/bar/launch-descriptor.ts | 25 ++++++++- .../bar-lifecycle-subcommands.test.ts | 56 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/commands/bar/launch-descriptor.ts b/src/commands/bar/launch-descriptor.ts index 61ededb0..71e3753e 100644 --- a/src/commands/bar/launch-descriptor.ts +++ b/src/commands/bar/launch-descriptor.ts @@ -8,6 +8,7 @@ * instead of the package-manager entrypoint. */ +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -17,6 +18,10 @@ import type { LaunchJson } from './bar-paths'; const SHIM_MODE = 0o700; +function sha256File(filePath: string): string { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + export interface LaunchDescriptorOptions { entrypointPath?: string; runtime?: string; @@ -38,11 +43,29 @@ function resolveEntrypoint(entrypointPath?: string): string { export function writeLaunchShim(home: string, entrypointPath?: string): string { const resolvedEntrypoint = resolveEntrypoint(entrypointPath); + const expectedEntrypointHash = sha256File(resolvedEntrypoint); const shimPath = getLaunchShimPath(home); const shimDir = path.dirname(shimPath); const contents = [ '#!/usr/bin/env node', - `require(${JSON.stringify(resolvedEntrypoint)});`, + "const crypto = require('crypto');", + "const fs = require('fs');", + "const Module = require('module');", + "const path = require('path');", + `const expectedEntrypoint = ${JSON.stringify(resolvedEntrypoint)};`, + `const expectedHash = ${JSON.stringify(expectedEntrypointHash)};`, + 'const resolvedEntrypoint = fs.realpathSync(expectedEntrypoint);', + "if (resolvedEntrypoint !== expectedEntrypoint) throw new Error('CCS Bar launch shim target changed. Run `ccs bar launch` to refresh launch.json.');", + 'const entrypointStat = fs.statSync(resolvedEntrypoint);', + "if (!entrypointStat.isFile()) throw new Error('CCS Bar launch shim target is not a regular file.');", + 'const source = fs.readFileSync(resolvedEntrypoint);', + "const actualHash = crypto.createHash('sha256').update(source).digest('hex');", + "if (actualHash !== expectedHash) throw new Error('CCS Bar launch shim target changed. Run `ccs bar launch` to refresh launch.json.');", + 'const targetModule = new Module(resolvedEntrypoint, module);', + 'targetModule.filename = resolvedEntrypoint;', + 'targetModule.paths = Module._nodeModulePaths(path.dirname(resolvedEntrypoint));', + 'require.cache[resolvedEntrypoint] = targetModule;', + "targetModule._compile(source.toString('utf8'), resolvedEntrypoint);", '', ].join('\n'); diff --git a/tests/unit/commands/bar-lifecycle-subcommands.test.ts b/tests/unit/commands/bar-lifecycle-subcommands.test.ts index d7a4d285..fdebbc2b 100644 --- a/tests/unit/commands/bar-lifecycle-subcommands.test.ts +++ b/tests/unit/commands/bar-lifecycle-subcommands.test.ts @@ -805,9 +805,61 @@ describe('launch descriptor shim', () => { const mode = fs.statSync(descriptor.args[0]).mode & 0o777; expect((mode & 0o022) === 0).toBe(true); const resolvedEntrypoint = fs.realpathSync(realEntrypoint); - expect(fs.readFileSync(descriptor.args[0], 'utf8')).toContain( - `require(${JSON.stringify(resolvedEntrypoint)});` + const shimContents = fs.readFileSync(descriptor.args[0], 'utf8'); + expect(shimContents).toContain( + `const expectedEntrypoint = ${JSON.stringify(resolvedEntrypoint)};` ); + expect(shimContents).toContain('const expectedHash = '); + expect(shimContents).toContain('const source = fs.readFileSync(resolvedEntrypoint);'); + expect(shimContents).toContain('if (actualHash !== expectedHash)'); + expect(shimContents).toContain('require.cache[resolvedEntrypoint] = targetModule;'); + expect(shimContents).toContain( + "targetModule._compile(source.toString('utf8'), resolvedEntrypoint);" + ); + expect(shimContents).not.toContain('require(resolvedEntrypoint);'); + }); + + it('rejects a modified original entrypoint before executing it', async () => { + const packageDist = path.join( + tempHome, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs', + 'dist' + ); + const binDir = path.join(tempHome, '.bun', 'bin'); + const markerPath = path.join(tempHome, 'attacker-marker'); + const realEntrypoint = path.join(packageDist, 'ccs.js'); + const symlinkedEntrypoint = path.join(binDir, 'ccs'); + + fs.mkdirSync(packageDist, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(realEntrypoint, 'console.log("original");\n', { mode: 0o777 }); + fs.symlinkSync(realEntrypoint, symlinkedEntrypoint); + + const { createBarLaunchDescriptor } = await loadLaunchDescriptor(); + const descriptor = createBarLaunchDescriptor({ + entrypointPath: symlinkedEntrypoint, + runtime: process.execPath, + home: tempHome, + }); + + fs.writeFileSync( + realEntrypoint, + `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed');\n` + ); + + const proc = Bun.spawnSync([descriptor.runtime, ...descriptor.args], { + stdout: 'pipe', + stderr: 'pipe', + }); + + expect(proc.exitCode).not.toBe(0); + expect(Buffer.from(proc.stderr).toString()).toContain('CCS Bar launch shim target changed'); + expect(fs.existsSync(markerPath)).toBe(false); }); }); From f2cd076c08b17eb0a7908f08f1398695add14488 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Jul 2026 04:54:25 +0000 Subject: [PATCH 03/35] chore(release): 8.7.0-dev.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9ff35544..f1bd1c96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0", + "version": "8.7.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 0f9a87619c84efd268b8b717b54fc7cd3a7e8a45 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:09:01 -0400 Subject: [PATCH 04/35] fix: cap native quota profile refresh fan-out (#1627) * fix: cap native quota profile refresh fan-out * fix: preserve native quota live cache state --- .../usage/native-quota-collector.ts | 165 ++++++++++++------ .../web-server/native-quota-collector.test.ts | 81 ++++++--- 2 files changed, 168 insertions(+), 78 deletions(-) diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 78a59d58..48466962 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -710,12 +710,72 @@ function codexProfileMatchesPausedAccount( /** * Tag a row with whether it is the surface's default profile (drives the * "active" badge only). The row's own `paused` flag is authoritative for - * dimming: it is already set by the collector to reflect LIVENESS — parked - * (no on-disk creds / unsupported) rows arrive with paused:true; profiles with - * a usable token arrive with paused:false regardless of default status. We must - * NOT override `paused` from `isDefault`, or a valid non-default subscription - * (e.g. an isolated `ccsx` profile) would render dimmed despite live quota. + * dimming: live/default rows arrive with paused:false, while non-default + * profiles are served cache-only and dimmed to avoid live-refresh fan-out. */ + +function buildParkedClaudeProfileRow(profile: string, now: number): BarSummaryRow { + return { + account_id: `${SURFACE_CLAUDE}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface: SURFACE_CLAUDE, + profile, + is_subscription: true, + displayName: profile, + tier: null, + paused: true, + quota_percentage: null, + quotaStatus: 'unsupported', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; +} + +function buildParkedCodexProfileRow(profile: string, now: number): BarSummaryRow { + return { + account_id: `${SURFACE_CODEX}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface: SURFACE_CODEX, + profile, + is_subscription: true, + displayName: profile, + tier: null, + paused: true, + quota_percentage: null, + quotaStatus: 'unsupported', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; +} + +function collectCachedOrParkedProfileRow( + map: Map, + profile: string, + now: number, + buildParkedRow: (profile: string, now: number) => BarSummaryRow +): BarSummaryRow { + const state = getState(map, profile); + if (state.cachedRow) { + return { ...state.cachedRow, cached: true, paused: true }; + } + const parkedRow = buildParkedRow(profile, now); + state.cachedRow = parkedRow; + state.cachedAt = now; + return parkedRow; +} + function markDefault(row: BarSummaryRow, isDefault: boolean): BarSummaryRow { return { ...row, is_default: isDefault }; } @@ -734,10 +794,11 @@ function markDefaultAndSyncCache( isDefault: boolean ): BarSummaryRow { const state = map.get(profile); + const marked = markDefault(row, isDefault); if (state?.cachedRow) { state.cachedRow = { ...state.cachedRow, is_default: isDefault }; } - return markDefault(row, isDefault); + return marked; } async function collectClaudeRowForProfile( @@ -789,26 +850,7 @@ async function collectClaudeRowForProfile( // user has not logged in via 'ccs auth' for this machine or the credentials // are stored only in keychain (which we deliberately do not access here). if (!creds) { - const parkedRow: BarSummaryRow = { - account_id: `${SURFACE_CLAUDE}:${profile}`, - provider: CLAUDE_NATIVE_PROVIDER, - surface: SURFACE_CLAUDE, - profile, - is_subscription: true, - displayName: profile, - tier: null, - paused: true, - quota_percentage: null, - quotaStatus: 'unsupported', - next_reset: null, - is_default: false, - last_activity_at: null, - today_cost: null, - health: 'ok', - cached: false, - fetchedAt: new Date(now).toISOString(), - needsReauth: true, - }; + const parkedRow = buildParkedClaudeProfileRow(profile, now); // Cache the parked row so repeated calls don't re-stat the fs. state.cachedRow = parkedRow; state.cachedAt = now; @@ -1062,26 +1104,7 @@ async function collectCodexRowForProfile( // profile. const stale = serveCached(state); if (stale) return stale; - const parkedRow: BarSummaryRow = { - account_id: `${SURFACE_CODEX}:${profile}`, - provider: CODEX_NATIVE_PROVIDER, - surface: SURFACE_CODEX, - profile, - is_subscription: true, - displayName: profile, - tier: null, - paused: true, - quota_percentage: null, - quotaStatus: 'unsupported', - next_reset: null, - is_default: false, - last_activity_at: null, - today_cost: null, - health: 'ok', - cached: false, - fetchedAt: new Date(now).toISOString(), - needsReauth: true, - }; + const parkedRow = buildParkedCodexProfileRow(profile, now); state.cachedRow = parkedRow; state.cachedAt = now; return parkedRow; @@ -1545,30 +1568,62 @@ async function getNativeAccountRowsMultiProfile( })(); const tasks: Promise[] = []; + const results: (BarSummaryRow | null)[] = []; + const now = (deps.now ?? Date.now)(); - // Forced refresh applies to every profile: parked profiles (no creds) short- - // circuit to a parked row with zero network, so forcing them is free, while - // every profile that has a usable token gets live quota — not just the - // default. Per-profile TTL + breaker still protect each account. + // Preserve the safety budget: only the active/default profile for each surface + // may perform a live refresh. Non-default profiles are cache-only (or parked) + // so one /summary request can trigger at most one Claude and one Codex live + // upstream call regardless of configured profile count. for (const p of claudeProfiles) { const isDefault = p === claudeDefault; + if (!isDefault) { + results.push( + markDefaultAndSyncCache( + claudeProfileStates, + p, + collectCachedOrParkedProfileRow(claudeProfileStates, p, now, buildParkedClaudeProfileRow), + false + ) + ); + continue; + } tasks.push( - collectClaudeRowForProfile(p, deps, force) - .then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, isDefault) : null)) + collectClaudeRowForProfile( + p, + deps, + force || claudeProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported' + ) + .then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, true) : null)) .catch(() => null) ); } for (const p of codexProfiles) { const isDefault = p === codexDefault; + if (!isDefault) { + results.push( + markDefaultAndSyncCache( + codexProfileStates, + p, + collectCachedOrParkedProfileRow(codexProfileStates, p, now, buildParkedCodexProfileRow), + false + ) + ); + continue; + } tasks.push( - collectCodexRowForProfile(p, deps, force) - .then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, isDefault) : null)) + collectCodexRowForProfile( + p, + deps, + force || codexProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported' + ) + .then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, true) : null)) .catch(() => null) ); } - const results = await Promise.all(tasks); + results.push(...(await Promise.all(tasks))); const rows = results.filter((r): r is BarSummaryRow => r !== null); // Sort by (surface, profile) for stable ordering. diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index d66abb5f..543086ae 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -989,7 +989,7 @@ describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => { * * - claudeProfiles: profile names for the Claude surface (ccs) * - codexProfiles: profile names for the Codex surface (ccsx) - * - claudeDefault / codexDefault: the active profile per surface (paused:false) + * - claudeDefault / codexDefault: the only live-polled profile per surface (paused:false) * - credsForProfile: map from profile name to credentials (null = parked) * - claudeFetch: network fetcher for Claude (all profiles share one implementation) * - codexNativeAuth: map from profile name to {accessToken, accountId} @@ -1113,13 +1113,13 @@ describe('multi-profile: account_id and wire fields', () => { expect(ck?.is_subscription).toBe(true); }); - it('paused reflects liveness (creds present), NOT default-ness; is_default marks the default independently', async () => { + it('only default profiles are live-polled; is_default marks the default independently', async () => { const clock = { now: 1_000_000 }; const deps = makeMultiProfileDeps({ clock, // Claude: work = default + creds (live); ck = non-default + NO creds (parked). claudeProfiles: ['work', 'ck'], - // Codex: personal = default + creds (live); ck = NON-default + creds (live). + // Codex: personal = default + creds (live); ck = NON-default + creds (cache-only). codexProfiles: ['personal', 'ck'], claudeDefault: 'work', codexDefault: 'personal', @@ -1146,11 +1146,11 @@ describe('multi-profile: account_id and wire fields', () => { expect(codexPersonal?.paused).toBe(false); expect(codexPersonal?.is_default).toBe(true); - // Codex ck: NON-default but HAS creds -> LIVE, NOT dimmed. This is the key - // correctness guarantee: a valid isolated subscription is never dimmed just - // because it is not the surface default. + // Codex ck: NON-default with creds is cache-only/parked. Only the default + // profile is live-polled so a dashboard request cannot fan out to every + // configured subscription account. const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck'); - expect(codexCk?.paused).toBe(false); + expect(codexCk?.paused).toBe(true); expect(codexCk?.is_default).toBe(false); }); @@ -1170,6 +1170,8 @@ describe('multi-profile: account_id and wire fields', () => { const rows = await getNativeAccountRows(deps); expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length); + expect(deps.claudeFetchCount()).toBe(1); + expect(deps.codexNetworkCount()).toBe(1); }); it('rows are sorted by (surface, profile)', async () => { @@ -1264,7 +1266,7 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { claudeDefault: 'work', credsForProfile: () => maxCreds(), claudeFetch: async (_token, accountId) => { - // 'work' (ccs:work) always 429s; 'ck' always succeeds + // 'work' (ccs:work) always 429s; 'ck' remains cache-only if (accountId?.includes('work') && workFails) { return { success: false, @@ -1294,8 +1296,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { }); } - // After the three 429s on 'work', check that 'ck' still succeeds. - // We reset state to have a clean run where 'ck' has no prior breaker history. + // After the three 429s on 'work', check that 'ck' remains cache-only. + // We reset state to have a clean run with no prior breaker history. resetNativeQuotaState(); clock.now += MAX_COOLDOWN_JUMP_MP; workFails = false; @@ -1304,8 +1306,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { const ckRow = rows.find((r) => r.profile === 'ck'); const workRow = rows.find((r) => r.profile === 'work'); - // 'ck' should succeed — its breaker was never tripped. - expect(ckRow?.quotaStatus).toBe('ok'); + // 'ck' stays cache-only, independent of work's breaker. + expect(ckRow?.quotaStatus).toBe('unsupported'); // 'work' is also fine after reset (no breaker state). expect(workRow?.quotaStatus).toBe('ok'); }); @@ -1339,10 +1341,10 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { }, }); - // First call: 'work' gets a 429, 'ck' succeeds. + // First call: 'work' gets a 429, 'ck' remains cache-only. const rows1 = await getNativeAccountRows(deps); const ck1 = rows1.find((r) => r.profile === 'ck'); - expect(ck1?.quotaStatus).toBe('ok'); + expect(ck1?.quotaStatus).toBe('unsupported'); expect(workCall429Count).toBeGreaterThanOrEqual(1); // Skip past cooldown for 'work' only; 'ck' is within TTL. @@ -1351,8 +1353,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => { // Second call past 'work' cooldown: work tries again (429 again); ck cached. const rows2 = await getNativeAccountRows(deps); const ck2 = rows2.find((r) => r.profile === 'ck'); - // 'ck' still has a good cached row. - expect(ck2?.quotaStatus).toBe('ok'); + // 'ck' remains cache-only and is not affected by work's breaker. + expect(ck2?.quotaStatus).toBe('unsupported'); }); }); @@ -1449,11 +1451,11 @@ describe('review focus areas: reauth caching + codex local fallback', () => { const r1 = first.find((r) => r.profile === 'ck'); expect(r1?.needsReauth).toBe(true); expect(r1?.paused).toBe(true); - expect(deps.codexNetworkCount()).toBe(1); + expect(deps.codexNetworkCount()).toBe(0); const second = await getNativeAccountRows(deps, { force: true }); expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true); - expect(deps.codexNetworkCount()).toBe(1); + expect(deps.codexNetworkCount()).toBe(0); }); it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => { @@ -1563,7 +1565,7 @@ describe('review focus areas: reauth caching + codex local fallback', () => { expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched }); - it('Codex named profile with valid auth but sparse payload stays active, not parked', async () => { + it('Codex named profile with valid auth but sparse payload stays parked when not default', async () => { resetNativeQuotaState(); const clock = { now: 7_000_000 }; const deps = makeMultiProfileDeps({ @@ -1579,9 +1581,42 @@ describe('review focus areas: reauth caching + codex local fallback', () => { const rows = await getNativeAccountRows(deps); const ck = rows.find((r) => r.profile === 'ck'); expect(ck).toBeDefined(); - expect(ck?.paused).toBe(false); // active subscription, not parked - expect(ck?.needsReauth).toBe(false); - expect(ck?.quotaStatus).toBe('ok'); - expect(ck?.quota_percentage).toBeNull(); // no windows, but still active + expect(ck?.paused).toBe(true); // cache-only, parked until selected as default + expect(ck?.needsReauth).toBe(true); + expect(ck?.quotaStatus).toBe('unsupported'); + expect(ck?.quota_percentage).toBeNull(); // no windows while parked + }); + + it('non-default cache-only rows do not overwrite the canonical live cache', async () => { + resetNativeQuotaState(); + const clock = { now: 8_000_000 }; + let codexDefault = 'ck'; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['default', 'ck'], + codexDefault, + codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + deps.defaultCodexProfile = () => codexDefault; + + const first = await getNativeAccountRows(deps); + expect(first.find((r) => r.profile === 'ck')?.paused).toBe(false); + expect(deps.codexNetworkCount()).toBe(1); + + codexDefault = 'default'; + const second = await getNativeAccountRows(deps); + const ckAsNonDefault = second.find((r) => r.profile === 'ck'); + expect(ckAsNonDefault?.cached).toBe(true); + expect(ckAsNonDefault?.paused).toBe(true); + expect(deps.codexNetworkCount()).toBe(2); + + codexDefault = 'ck'; + const third = await getNativeAccountRows(deps); + const ckDefaultAgain = third.find((r) => r.profile === 'ck'); + expect(ckDefaultAgain?.cached).toBe(true); + expect(ckDefaultAgain?.paused).toBe(false); + expect(deps.codexNetworkCount()).toBe(2); }); }); From 20df3445d91b82ceca5a22b398f74ec12d353c3f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Jul 2026 05:13:16 +0000 Subject: [PATCH 05/35] chore(release): 8.7.0-dev.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f1bd1c96..8c016105 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.1", + "version": "8.7.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 343899f6c1529cbeb6e0a518a61908fd3171f20f Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:15:45 -0400 Subject: [PATCH 06/35] fix: prevent default profile credential collision (#1628) * fix: prevent default profile credential collision * fix: isolate default profile credential fallback --- src/auth/account-context.ts | 6 +- .../usage/native-quota-collector.ts | 40 +++++---- tests/unit/account-context.test.ts | 5 ++ .../web-server/native-quota-collector.test.ts | 90 +++++++++++++++++++ 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index a8c28b50..52ce2eb8 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -36,6 +36,7 @@ export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default'; export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard'; export const MAX_CONTEXT_GROUP_LENGTH = 64; export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +const RESERVED_ACCOUNT_PROFILE_NAMES = new Set(['default']); const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -57,7 +58,10 @@ export function isValidContextGroupName(value: string): boolean { * Validate account profile naming constraints. */ export function isValidAccountProfileName(value: string): boolean { - return ACCOUNT_PROFILE_NAME_PATTERN.test(value); + return ( + ACCOUNT_PROFILE_NAME_PATTERN.test(value) && + !RESERVED_ACCOUNT_PROFILE_NAMES.has(value.toLowerCase()) + ); } /** diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 48466962..ecf942c3 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -497,24 +497,31 @@ function serveCached(state: ProviderState): BarSummaryRow | null { * is absent or unparseable, returns null — the caller emits a parked row. * Never calls security/Keychain — zero new keychain access from this feature. */ -function readClaudeCredentialsForProfileFromDisk(profile: string): ClaudeNativeCredentials | null { - // The bare `ccs` default login uses the standard global credential lookup: - // ~/.claude/.credentials.json, falling back to the single global - // "Claude Code-credentials" Keychain item that Claude Code itself maintains. - // This is the ONE pre-existing global read the shipped Bar already performs -- - // NOT a per-profile Keychain scan. Isolated `ccs auth` profiles below stay - // file-only and never touch the Keychain. - if (profile === DEFAULT_PROFILE) { - return readClaudeCredentials(); - } +function readClaudeCredentialsForProfileFromDisk( + profile: string, + readDefaultCredentials: () => ClaudeNativeCredentials | null = readClaudeCredentials +): ClaudeNativeCredentials | null { try { const instanceDir = path.join(getCcsDir(), 'instances', profile); const credFile = path.join(instanceDir, '.credentials.json'); - if (!fs.existsSync(credFile)) return null; - const raw = fs.readFileSync(credFile, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as ClaudeNativeCredentials; + if (fs.existsSync(credFile)) { + const raw = fs.readFileSync(credFile, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as ClaudeNativeCredentials; + } + return null; + } + + // The bare `ccs` default login uses the standard global credential lookup: + // ~/.claude/.credentials.json, falling back to the single global + // "Claude Code-credentials" Keychain item that Claude Code itself maintains. + // This is the ONE pre-existing global read the shipped Bar already performs -- + // NOT a per-profile Keychain scan. Isolated `ccs auth` profiles stay + // file-only and never touch the Keychain; a real instance directory named + // "default" is therefore parked when its file is absent. + if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) { + return readDefaultCredentials(); } return null; } catch { @@ -829,9 +836,10 @@ async function collectClaudeRowForProfile( } // For per-profile reads: use the injected seam (file-only, no keychain). + const readDefaultCredentials = deps.readCredentials ?? readClaudeCredentials; const readCreds = deps.readClaudeCredentialsForProfile ?? - ((p: string) => readClaudeCredentialsForProfileFromDisk(p)); + ((p: string) => readClaudeCredentialsForProfileFromDisk(p, readDefaultCredentials)); const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; const sleep = deps.sleep ?? defaultSleep; diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index ecb21c74..affb01b3 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -20,6 +20,11 @@ describe('account context helpers', () => { expect(isValidAccountProfileName('gemini:default')).toBe(false); }); + it('rejects reserved account profile names', () => { + expect(isValidAccountProfileName('default')).toBe(false); + expect(isValidAccountProfileName('Default')).toBe(false); + }); + it('falls back to default shared group for invalid persisted metadata', () => { const resolved = resolveAccountContextPolicy({ context_mode: 'shared', diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 543086ae..9c24fa0f 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -1251,6 +1251,96 @@ describe('multi-profile: Claude file-only reader', () => { expect(row?.quotaStatus).toBe('unsupported'); expect(row?.is_subscription).toBe(true); }); + + it('existing default profile directory without creds does not read global credentials', async () => { + const originalCcsHome = process.env.CCS_HOME; + const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-')); + const clock = { now: 1_000_000 }; + let defaultReadCount = 0; + let fetchCount = 0; + + try { + process.env.CCS_HOME = tempCcsHome; + fs.mkdirSync(path.join(tempCcsHome, '.ccs', 'instances', 'default'), { recursive: true }); + + const rows = await getNativeAccountRows({ + readCredentials: () => { + defaultReadCount += 1; + return { claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' } }; + }, + listClaudeProfiles: () => ['default'], + listCodexProfiles: () => [], + defaultClaudeProfile: () => 'default', + defaultCodexProfile: () => null, + fetchClaudeQuota: async () => { + fetchCount += 1; + return successQuota(); + }, + getCodexQuota: async () => null, + now: () => clock.now, + sleep: async () => {}, + }); + + const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default'); + expect(defaultReadCount).toBe(0); + expect(fetchCount).toBe(0); + expect(row?.needsReauth).toBe(true); + expect(row?.quotaStatus).toBe('unsupported'); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempCcsHome, { recursive: true, force: true }); + } + }); + + it('existing default profile credentials win over global credentials', async () => { + const originalCcsHome = process.env.CCS_HOME; + const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-')); + const clock = { now: 1_000_000 }; + let fetchedToken: string | null = null; + + try { + process.env.CCS_HOME = tempCcsHome; + const defaultInstanceDir = path.join(tempCcsHome, '.ccs', 'instances', 'default'); + fs.mkdirSync(defaultInstanceDir, { recursive: true }); + fs.writeFileSync( + path.join(defaultInstanceDir, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'profile-token', subscriptionType: 'max' } }) + ); + + const rows = await getNativeAccountRows({ + readCredentials: () => ({ + claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' }, + }), + listClaudeProfiles: () => ['default'], + listCodexProfiles: () => [], + defaultClaudeProfile: () => 'default', + defaultCodexProfile: () => null, + fetchClaudeQuota: async (token) => { + fetchedToken = token; + return successQuota(); + }, + getCodexQuota: async () => null, + now: () => clock.now, + sleep: async () => {}, + }); + + const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default'); + expect(fetchedToken).toBe('profile-token'); + expect(row?.needsReauth).toBe(false); + expect(row?.quotaStatus).toBe('ok'); + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempCcsHome, { recursive: true, force: true }); + } + }); }); describe('multi-profile: per-profile circuit breaker isolation', () => { From 53fe7e1e8d890eff1d4145583afc271e642a810e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Jul 2026 05:20:05 +0000 Subject: [PATCH 07/35] chore(release): 8.7.0-dev.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8c016105..ede22bf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.2", + "version": "8.7.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 1ee7cac5552313018bf14100a3393b90b31dbcfd Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:26:48 -0400 Subject: [PATCH 08/35] fix: reject case-variant symlinked file paths (#1629) * fix: reject case-variant symlinked file paths * fix: validate requested case-variant symlink paths --- src/web-server/routes/route-helpers.ts | 46 +++++++++++-- tests/unit/web-server/route-helpers.test.ts | 76 +++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 7e5ace2d..2fedac56 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -399,14 +399,50 @@ function isSymlinkPath(filePath: string): boolean { } } -function hasSymlinkSegment(basePath: string, targetPath: string): boolean { - const relative = path.relative(basePath, targetPath); +function getRequestedBasePath(basePath: string, targetPath: string): string { + const comparisonBasePath = normalizePathForComparison(basePath); + const comparisonTargetPath = normalizePathForComparison(targetPath); + const comparisonBaseWithSeparator = comparisonBasePath.endsWith(path.sep) + ? comparisonBasePath + : `${comparisonBasePath}${path.sep}`; + + if ( + comparisonTargetPath === comparisonBasePath || + comparisonTargetPath.startsWith(comparisonBaseWithSeparator) + ) { + return targetPath.slice(0, basePath.length); + } + + return basePath; +} + +function hasSymlinkSegment( + basePath: string, + targetPath: string, + comparisonBasePath: string, + comparisonTargetPath: string +): boolean { + const relative = path.relative(comparisonBasePath, comparisonTargetPath); if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { return false; } - let currentPath = basePath; - const segments = relative.split(path.sep).filter(Boolean); + const requestedBasePath = getRequestedBasePath(basePath, targetPath); + if (requestedBasePath !== basePath && isSymlinkPath(requestedBasePath)) { + return true; + } + + const requestedRelative = path.relative(requestedBasePath, targetPath); + if ( + requestedRelative === '' || + requestedRelative.startsWith('..') || + path.isAbsolute(requestedRelative) + ) { + return false; + } + + let currentPath = requestedBasePath; + const segments = requestedRelative.split(path.sep).filter(Boolean); for (const segment of segments) { currentPath = path.join(currentPath, segment); if (isSymlinkPath(currentPath)) { @@ -432,7 +468,7 @@ export function validateFilePath(filePath: string): { // Check if path is within ~/.ccs/ if (isPathWithin(ccsDir, normalizedPath)) { - if (hasSymlinkSegment(resolvedCcsDir, resolvedPath)) { + if (hasSymlinkSegment(resolvedCcsDir, resolvedPath, ccsDir, normalizedPath)) { return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; } diff --git a/tests/unit/web-server/route-helpers.test.ts b/tests/unit/web-server/route-helpers.test.ts index 581d1ee9..1cd4cbac 100644 --- a/tests/unit/web-server/route-helpers.test.ts +++ b/tests/unit/web-server/route-helpers.test.ts @@ -99,6 +99,82 @@ describe('validateFilePath', () => { expect(result.readonly).toBe(false); }); + test('rejects macOS case-variant paths through symlinked segments', () => { + if (process.platform === 'win32') { + return; + } + + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + try { + const ccsDir = path.join(tempDir, '.ccs'); + const caseVariantCcsDir = path.join(tempDir, '.CCS'); + const sharedDir = path.join(ccsDir, 'shared'); + const outsideCommandsDir = path.join(tempDir, '.claude', 'commands'); + const linkedCommandsDir = path.join(sharedDir, 'commands'); + + fs.mkdirSync(sharedDir, { recursive: true }); + fs.mkdirSync(outsideCommandsDir, { recursive: true }); + try { + fs.symlinkSync(ccsDir, caseVariantCcsDir, 'dir'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== 'EEXIST') { + throw error; + } + } + fs.symlinkSync(outsideCommandsDir, linkedCommandsDir, 'dir'); + + const result = validateFilePath( + path.join(caseVariantCcsDir, 'shared', 'commands', 'pwned.md') + ); + + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + + test('rejects macOS case-variant CCS directory symlinks', () => { + if (process.platform === 'win32') { + return; + } + + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + try { + const ccsDir = path.join(tempDir, '.ccs'); + const caseVariantCcsDir = path.join(tempDir, '.CCS'); + const outsideDir = path.join(tempDir, 'outside'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + try { + fs.symlinkSync(outsideDir, caseVariantCcsDir, 'dir'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'EEXIST') { + return; + } + throw error; + } + + const result = validateFilePath(path.join(caseVariantCcsDir, 'pwned.md')); + + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + test('rejects symlinked Claude settings path', () => { if (process.platform === 'win32') { return; From 1aacbfd2a8156136992ffb623bbccdbf486c33a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Jul 2026 05:30:54 +0000 Subject: [PATCH 09/35] chore(release): 8.7.0-dev.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ede22bf3..5b53c454 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.3", + "version": "8.7.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 631a515986cd2224c3d89d8498399700f19e22c7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 2 Jul 2026 10:10:28 -0400 Subject: [PATCH 10/35] feat: support custom GHCP model selection --- .../cliproxy/provider-model-selector.tsx | 39 +++++ ui/src/components/ui/searchable-select.tsx | 22 ++- ui/src/lib/i18n.ts | 5 + ui/src/lib/model-catalogs.ts | 148 +++++++++++++++--- .../cliproxy/provider-model-selector.test.tsx | 41 +++++ .../components/ui/searchable-select.test.tsx | 52 ++++++ .../unit/ui/lib/model-catalogs-ghcp.test.ts | 43 +++++ 7 files changed, 324 insertions(+), 26 deletions(-) create mode 100644 ui/tests/unit/ui/lib/model-catalogs-ghcp.test.ts diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 350ed4d1..aa644703 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -72,6 +72,17 @@ interface ProviderModelSelectorProps { className?: string; } +const CUSTOM_MODEL_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/; +const CUSTOM_MODEL_ID_SEPARATOR_PATTERN = /[._:/-]/; + +function isCustomModelId(value: string): boolean { + const trimmedValue = value.trim(); + return ( + CUSTOM_MODEL_ID_PATTERN.test(trimmedValue) && + CUSTOM_MODEL_ID_SEPARATOR_PATTERN.test(trimmedValue) + ); +} + function PaidBadge({ label }: { label: string }) { return ( @@ -396,6 +407,25 @@ export function FlexibleModelSelector({ () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), [routing, routingHints, value] ); + const createCustomModelOption = (query: string) => { + const customModelId = query.trim(); + if (!isCustomModelId(customModelId)) return null; + + return { + value: customModelId, + groupKey: 'custom', + searchText: customModelId, + triggerContent: {customModelId}, + itemContent: ( +
+ {customModelId} + + {t('providerModelSelector.customModel')} + +
+ ), + }; + }; const recommendedOptions = resolvedCatalogModels.flatMap((model) => { const routingHint = routingHints.get(model.id.toLowerCase()); @@ -544,6 +574,7 @@ export function FlexibleModelSelector({ : t('providerModelSelector.noModelsAvailable') } triggerClassName="h-9" + createOption={createCustomModelOption} groups={[ ...(selectedValueMissing && legacySelectedOption ? [ @@ -597,6 +628,14 @@ export function FlexibleModelSelector({ }, ] : []), + { + key: 'custom', + label: ( + + {t('providerModelSelector.customModel')} + + ), + }, ]} options={[ ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx index b971bb0f..38a6f937 100644 --- a/ui/src/components/ui/searchable-select.tsx +++ b/ui/src/components/ui/searchable-select.tsx @@ -25,6 +25,7 @@ interface SearchableSelectProps { value?: string; onChange: (value: string) => void; options: SearchableSelectOption[]; + createOption?: (query: string) => SearchableSelectOption | null | undefined; groups?: SearchableSelectGroup[]; placeholder: string; searchPlaceholder: string; @@ -47,6 +48,7 @@ export function SearchableSelect({ value, onChange, options, + createOption, groups, placeholder, searchPlaceholder, @@ -64,20 +66,32 @@ export function SearchableSelect({ const optionRefs = React.useRef>({}); const selectedOption = React.useMemo( - () => options.find((option) => option.value === value), - [options, value] + () => + options.find((option) => option.value === value) ?? + (value ? (createOption?.(value) ?? undefined) : undefined), + [createOption, options, value] ); const filteredOptions = React.useMemo(() => { const normalizedQuery = normalizeSearch(query); if (!normalizedQuery) return options; - return options.filter((option) => + const matchedOptions = options.filter((option) => [option.searchText, ...(option.keywords ?? [])].some((candidate) => normalizeSearch(candidate).includes(normalizedQuery) ) ); - }, [options, query]); + const createdOption = createOption?.(query.trim()) ?? null; + if (!createdOption) return matchedOptions; + + const normalizedCreatedValue = normalizeSearch(createdOption.value); + const optionAlreadyExists = options.some( + (option) => normalizeSearch(option.value) === normalizedCreatedValue + ); + if (optionAlreadyExists) return matchedOptions; + + return [...matchedOptions, createdOption]; + }, [createOption, options, query]); const groupedOptions = React.useMemo(() => { const knownGroups = new Map((groups ?? []).map((group) => [group.key, group])); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 50f097ef..39988ff4 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -221,6 +221,7 @@ const resources = { prefixOnly: 'Prefix only', current: 'Current', currentValue: 'Current value', + customModel: 'Custom model', preferredPinnedModel: 'Preferred pinned model:', pinnedRouteStatus: 'Pinned route status:', pinnedModelNotAdvertised: @@ -2947,6 +2948,7 @@ const resources = { prefixOnly: '仅前缀', current: '当前', currentValue: '当前值', + customModel: '自定义模型', preferredPinnedModel: '偏好的固定模型:', pinnedRouteStatus: '固定路由状态:', pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}', @@ -5555,6 +5557,7 @@ const resources = { prefixOnly: 'Chỉ tiền tố', current: 'Hiện tại', currentValue: 'Giá trị hiện tại', + customModel: 'Mô hình tùy chỉnh', preferredPinnedModel: 'Mô hình ghim ưu tiên:', pinnedRouteStatus: 'Trạng thái tuyến ghim:', pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}', @@ -8279,6 +8282,7 @@ const resources = { prefixOnly: 'プレフィックスのみ', current: '現在', currentValue: '現在の値', + customModel: 'カスタムモデル', preferredPinnedModel: '優先ピンモデル:', pinnedRouteStatus: 'ピンルートの状態:', pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}', @@ -11026,6 +11030,7 @@ const resources = { prefixOnly: '접두사 전용', current: '현재', currentValue: '현재 값', + customModel: '사용자 지정 모델', preferredPinnedModel: '선호하는 고정 모델:', pinnedRouteStatus: '고정된 라우트 상태:', pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 4292774c..fa877db2 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -680,8 +680,50 @@ export const MODEL_CATALOGS: Record = { ghcp: { provider: 'ghcp', displayName: 'GitHub Copilot (OAuth)', - defaultModel: 'claude-sonnet-4.5', + defaultModel: 'claude-sonnet-5', models: [ + { + id: 'claude-sonnet-5', + name: 'Claude Sonnet 5', + description: 'Anthropic Claude Sonnet 5 via GitHub Copilot', + extendedContext: true, + presetMapping: { + default: 'claude-sonnet-5', + opus: 'claude-opus-4.8', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4.5', + }, + }, + { + id: 'claude-opus-4.8', + name: 'Claude Opus 4.8', + description: 'Anthropic Claude Opus 4.8 via GitHub Copilot', + extendedContext: true, + presetMapping: { + default: 'claude-opus-4.8', + opus: 'claude-opus-4.8', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4.5', + }, + }, + { + id: 'claude-opus-4.8-fast-mode', + name: 'Claude Opus 4.8 Fast Mode', + description: 'Anthropic Claude Opus 4.8 preview fast mode via GitHub Copilot', + extendedContext: true, + }, + { + id: 'claude-opus-4.7', + name: 'Claude Opus 4.7', + description: 'Anthropic Claude Opus 4.7 via GitHub Copilot', + extendedContext: true, + }, + { + id: 'claude-opus-4.6', + name: 'Claude Opus 4.6', + description: 'Anthropic Claude Opus 4.6 via GitHub Copilot', + extendedContext: true, + }, { id: 'claude-opus-4.5', name: 'Claude Opus 4.5', @@ -693,6 +735,18 @@ export const MODEL_CATALOGS: Record = { haiku: 'claude-haiku-4.5', }, }, + { + id: 'claude-sonnet-4.6', + name: 'Claude Sonnet 4.6', + description: 'Anthropic Claude Sonnet 4.6 via GitHub Copilot', + extendedContext: true, + presetMapping: { + default: 'claude-sonnet-4.6', + opus: 'claude-opus-4.7', + sonnet: 'claude-sonnet-4.6', + haiku: 'claude-haiku-4.5', + }, + }, { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', @@ -704,36 +758,56 @@ export const MODEL_CATALOGS: Record = { haiku: 'claude-haiku-4.5', }, }, - { - id: 'claude-sonnet-4', - name: 'Claude Sonnet 4', - description: 'Anthropic Claude Sonnet 4 via GitHub Copilot', - }, { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', description: 'Anthropic Claude Haiku 4.5 via GitHub Copilot', }, { - id: 'gpt-5.2', - name: 'GPT-5.2', - description: 'OpenAI GPT-5.2 via GitHub Copilot', + id: 'claude-fable-5', + name: 'Claude Fable 5', + description: 'Anthropic Claude Fable 5 via GitHub Copilot', + extendedContext: true, + }, + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'OpenAI GPT-5.5 via GitHub Copilot', + extendedContext: true, presetMapping: { - default: 'gpt-5.2', - opus: 'gpt-5.2', - sonnet: 'gpt-5.1', - haiku: 'gpt-5-mini', + default: 'gpt-5.5', + opus: 'gpt-5.5', + sonnet: 'gpt-5.4', + haiku: 'gpt-5.4-mini', }, }, { - id: 'gpt-5.1', - name: 'GPT-5.1', - description: 'OpenAI GPT-5.1 via GitHub Copilot', + id: 'gpt-5.4', + name: 'GPT-5.4', + description: 'OpenAI GPT-5.4 via GitHub Copilot', + extendedContext: true, }, { - id: 'gpt-5', - name: 'GPT-5', - description: 'OpenAI GPT-5 via GitHub Copilot', + id: 'gpt-5.4-mini', + name: 'GPT-5.4 Mini', + description: 'OpenAI GPT-5.4 Mini via GitHub Copilot', + }, + { + id: 'gpt-5.4-nano', + name: 'GPT-5.4 Nano', + description: 'OpenAI GPT-5.4 Nano via GitHub Copilot', + }, + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + description: 'OpenAI GPT-5.3 Codex via GitHub Copilot', + extendedContext: true, + presetMapping: { + default: 'gpt-5.3-codex', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5-mini', + }, }, { id: 'gpt-5-mini', @@ -741,9 +815,39 @@ export const MODEL_CATALOGS: Record = { description: 'OpenAI GPT-5 Mini via GitHub Copilot', }, { - id: 'gemini-3-pro', - name: 'Gemini 3 Pro', - description: 'Google Gemini 3 Pro via GitHub Copilot', + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + description: 'Google Gemini 2.5 Pro via GitHub Copilot', + }, + { + id: 'gemini-3-flash', + name: 'Gemini 3 Flash', + description: 'Google Gemini 3 Flash via GitHub Copilot', + }, + { + id: 'gemini-3.1-pro', + name: 'Gemini 3.1 Pro', + description: 'Google Gemini 3.1 Pro via GitHub Copilot', + }, + { + id: 'gemini-3.5-flash', + name: 'Gemini 3.5 Flash', + description: 'Google Gemini 3.5 Flash via GitHub Copilot', + }, + { + id: 'kimi-k2.7-code', + name: 'Kimi K2.7 Code', + description: 'Moonshot Kimi K2.7 Code via GitHub Copilot', + }, + { + id: 'mai-code-1-flash', + name: 'MAI-Code-1-Flash', + description: 'Microsoft MAI-Code-1-Flash via GitHub Copilot', + }, + { + id: 'raptor-mini', + name: 'Raptor Mini', + description: 'Microsoft Raptor Mini via GitHub Copilot', }, ], }, diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index fd2e3bd0..0149a437 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -88,6 +88,47 @@ describe('FlexibleModelSelector', () => { expect(screen.getByRole('option', { name: /claude-sonnet-4-6/i })).toBeInTheDocument(); }); + it('surfaces Claude Sonnet 5 in the GitHub Copilot CLIProxy picker', async () => { + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'sonnet'); + + expect(screen.getByRole('option', { name: /claude-sonnet-5/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /claude-sonnet-4\.6/i })).toBeInTheDocument(); + expect(screen.queryByText('Custom model')).not.toBeInTheDocument(); + }); + + it('lets users enter a future GitHub Copilot model ID from the CLIProxy picker', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-6'); + + expect(screen.getAllByText('Custom model').length).toBeGreaterThan(0); + await userEvent.click(screen.getByRole('option', { name: /claude-sonnet-6/i })); + + expect(onChange).toHaveBeenCalledWith('claude-sonnet-6'); + }); + it('preserves a filtered legacy value under the current-value fallback group', async () => { render( (); + + return ( + + /^[a-z0-9][a-z0-9.-]*$/i.test(query) + ? { + value: query, + groupKey: 'custom', + searchText: query, + itemContent: Use custom model {query}, + } + : null + } + options={[ + { + value: 'claude-sonnet-4', + groupKey: 'known', + searchText: 'Claude Sonnet 4 claude-sonnet-4', + itemContent: Claude Sonnet 4, + }, + ]} + /> + ); +} + describe('SearchableSelect', () => { beforeEach(() => { Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { @@ -94,6 +130,22 @@ describe('SearchableSelect', () => { expect(screen.getByText('No results found.')).toBeInTheDocument(); }); + it('can append and select a custom option from the search query', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-5'); + + expect(screen.getByText('Custom Model')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('option', { name: 'Use custom model claude-sonnet-5' })); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Use custom model claude-sonnet-5' }) + ).toBeInTheDocument(); + }); + }); + it('supports keyboard navigation and selection from the search input', async () => { render(); diff --git a/ui/tests/unit/ui/lib/model-catalogs-ghcp.test.ts b/ui/tests/unit/ui/lib/model-catalogs-ghcp.test.ts new file mode 100644 index 00000000..96537eb8 --- /dev/null +++ b/ui/tests/unit/ui/lib/model-catalogs-ghcp.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { MODEL_CATALOGS } from '@/lib/model-catalogs'; + +describe('GitHub Copilot model catalog defaults', () => { + it('surfaces Claude Sonnet 5 as the default Copilot preset model', () => { + const ghcpCatalog = MODEL_CATALOGS.ghcp; + const sonnet5 = ghcpCatalog.models.find((model) => model.id === 'claude-sonnet-5'); + + expect(ghcpCatalog.defaultModel).toBe('claude-sonnet-5'); + expect(sonnet5?.name).toBe('Claude Sonnet 5'); + expect(sonnet5?.extendedContext).toBe(true); + expect(sonnet5?.presetMapping).toEqual({ + default: 'claude-sonnet-5', + opus: 'claude-opus-4.8', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4.5', + }); + }); + + it('advertises current Copilot models without retired recommendations', () => { + const ids = MODEL_CATALOGS.ghcp.models.map((model) => model.id); + + expect(ids).toEqual( + expect.arrayContaining([ + 'claude-sonnet-5', + 'claude-opus-4.8', + 'claude-opus-4.8-fast-mode', + 'claude-fable-5', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.3-codex', + 'gemini-3.5-flash', + 'kimi-k2.7-code', + 'mai-code-1-flash', + 'raptor-mini', + ]) + ); + expect(ids).not.toEqual( + expect.arrayContaining(['claude-sonnet-4', 'gpt-5.2', 'gpt-5.1', 'gpt-5', 'gemini-3-pro']) + ); + }); +}); From c8a4b429906d0c9e08dc8ea27625676ff15c4bba Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 2 Jul 2026 10:31:42 -0400 Subject: [PATCH 11/35] feat: add Sonnet 5 to Claude provider catalog --- src/cliproxy/__tests__/model-catalog.test.js | 14 +++++++++- .../__tests__/thinking-validator.test.ts | 7 +++++ src/cliproxy/model-catalog.ts | 16 ++++++++++- ui/src/lib/model-catalogs.ts | 20 ++++++++++--- .../cliproxy/provider-model-selector.test.tsx | 28 +++++++++++++++++-- ui/tests/unit/ui/lib/preset-utils.test.ts | 15 +++++----- 6 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index eae74a31..32f4cecd 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -132,7 +132,19 @@ describe('Model Catalog', () => { }); it('has correct default model', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.claude.defaultModel, 'claude-sonnet-4-6'); + assert.strictEqual(MODEL_CATALOG.claude.defaultModel, 'claude-sonnet-5'); + }); + + it('includes Claude Sonnet 5 with adaptive levels and extended context', () => { + const { MODEL_CATALOG } = modelCatalog; + const sonnet = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-sonnet-5'); + assert(sonnet, 'Should include Claude Sonnet 5'); + assert.strictEqual(sonnet.name, 'Claude Sonnet 5'); + assert.strictEqual(sonnet.thinking.type, 'levels'); + assert.deepStrictEqual(sonnet.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); + assert.strictEqual(sonnet.thinking.maxLevel, 'max'); + assert.strictEqual(sonnet.nativeImageInput, true); + assert.strictEqual(sonnet.extendedContext, true); }); it('includes Claude Sonnet 4.6', () => { diff --git a/src/cliproxy/__tests__/thinking-validator.test.ts b/src/cliproxy/__tests__/thinking-validator.test.ts index 8dfd47c9..b6a47489 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -69,6 +69,13 @@ describe('Thinking Validator', () => { expect(result.warning).toBeUndefined(); }); + it('should treat max as a distinct top tier on Claude Sonnet 5', () => { + const result = validateThinking('claude', 'claude-sonnet-5', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + it('should still alias max -> xhigh for models without a max level (backcompat)', () => { // Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'. // User input "max" should map down to xhigh rather than be rejected. diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 5c2ade81..8ba19731 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -367,8 +367,22 @@ export const MODEL_CATALOG: Partial> = claude: { provider: 'claude', displayName: 'Claude (Anthropic)', - defaultModel: 'claude-sonnet-4-6', + defaultModel: 'claude-sonnet-5', models: [ + { + id: 'claude-sonnet-5', + name: 'Claude Sonnet 5', + description: 'Latest Sonnet model', + nativeImageInput: true, + // Sonnet 5 uses adaptive thinking; manual budget_tokens is rejected with 400. + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index fa877db2..1d358ee7 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -854,8 +854,20 @@ export const MODEL_CATALOGS: Record = { claude: { provider: 'claude', displayName: 'Claude (Anthropic)', - defaultModel: 'claude-sonnet-4-6', + defaultModel: 'claude-sonnet-5', models: [ + { + id: 'claude-sonnet-5', + name: 'Claude Sonnet 5', + description: 'Latest Sonnet model', + extendedContext: true, + presetMapping: { + default: 'claude-sonnet-5', + opus: 'claude-opus-4-8', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', @@ -864,7 +876,7 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-fable-5', opus: 'claude-fable-5', - sonnet: 'claude-sonnet-4-6', + sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001', }, }, @@ -876,7 +888,7 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-opus-4-8', opus: 'claude-opus-4-8', - sonnet: 'claude-sonnet-4-6', + sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001', }, }, @@ -888,7 +900,7 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-opus-4-7', opus: 'claude-opus-4-7', - sonnet: 'claude-sonnet-4-6', + sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001', }, }, diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index 0149a437..4c71ee7b 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -70,7 +70,7 @@ describe('FlexibleModelSelector', () => { expect(screen.queryByText(/All Models \(/i)).not.toBeInTheDocument(); }); - it('surfaces Claude Opus 4.7 in the curated Claude picker', async () => { + it('surfaces Claude Sonnet 5 in the curated Claude picker', async () => { render( { await userEvent.click(screen.getByRole('button', { name: /select model/i })); - expect(screen.getByRole('option', { name: /claude-opus-4-7/i })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: /claude-opus-4-6/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /claude-sonnet-5/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /claude-opus-4-8/i })).toBeInTheDocument(); expect(screen.getByRole('option', { name: /claude-sonnet-4-6/i })).toBeInTheDocument(); }); @@ -129,6 +129,28 @@ describe('FlexibleModelSelector', () => { expect(onChange).toHaveBeenCalledWith('claude-sonnet-6'); }); + it('lets users enter a future direct Claude model ID from the CLIProxy picker', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-6'); + + expect(screen.getAllByText('Custom model').length).toBeGreaterThan(0); + await userEvent.click(screen.getByRole('option', { name: /claude-sonnet-6/i })); + + expect(onChange).toHaveBeenCalledWith('claude-sonnet-6'); + }); + it('preserves a filtered legacy value under the current-value fallback group', async () => { render( { vi.restoreAllMocks(); }); - it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7/4.8 and Fable 5', () => { + it('keeps the claude catalog default on Sonnet 5 while exposing recent Claude models', () => { const claudeCatalog = MODEL_CATALOGS.claude; const ids = claudeCatalog.models.map((model) => model.id); - expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6'); + expect(claudeCatalog.defaultModel).toBe('claude-sonnet-5'); + expect(ids).toContain('claude-sonnet-5'); expect(ids).toContain('claude-fable-5'); expect(ids).toContain('claude-opus-4-8'); expect(ids).toContain('claude-opus-4-7'); @@ -50,15 +51,15 @@ describe('claude preset utils', () => { const result = await applyDefaultPreset('claude'); - expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' }); + expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 5' }); const [, requestInit] = fetchMock.mock.calls[2] ?? []; const body = JSON.parse(String(requestInit?.body)); expect(body.settings.env).toMatchObject({ - ANTHROPIC_MODEL: 'claude-sonnet-4-6', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_MODEL: 'claude-sonnet-5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-5', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }); }); @@ -76,7 +77,7 @@ describe('claude preset utils', () => { const result = await applyDefaultPreset('claude', undefined, MODEL_CATALOGS.claude); - expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' }); + expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 5' }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/settings/auth/tokens/raw'); expect(fetchMock).toHaveBeenNthCalledWith( From f1eee3029ee68b43bacfe1bdb3e7cc416e78836a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Jul 2026 15:15:30 +0000 Subject: [PATCH 12/35] chore(release): 8.7.0-dev.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b53c454..ceb9f668 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.4", + "version": "8.7.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 1e63c37baf94468b855cf583453816fadcb209e8 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Tue, 7 Jul 2026 14:51:43 +0200 Subject: [PATCH 13/35] feat(pricing): register Claude Sonnet 5 in the pricing registry Add explicit `claude-sonnet-5` and `claude-sonnet-5-thinking` entries to the web-server PRICING_REGISTRY at the standard Sonnet rates ($3/$15 in/out, $3.75/$0.30 cache). The model catalog and dashboard already ship Sonnet 5 (it is the Claude default), but pricing was missing: getModelPricing() fell through to UNKNOWN_MODEL_PRICING, which only coincidentally matches Sonnet rates. As a result hasCustomPricing('claude-sonnet-5') returned false and getKnownModels() omitted it, so Sonnet 5 was treated as an unknown model in cost accounting and known-model listings. Registering it explicitly makes the pricing intentional and consistent with every other Claude model, without changing any resolved values. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 15 ++++++++++++++ tests/unit/model-pricing.test.ts | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 9a176882..c07f4e3a 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -216,6 +216,21 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 3.75, cacheReadPerMillion: 0.3, }, + // Claude Sonnet 5 ($3/$15) — latest Sonnet; shares the standard Sonnet rates. + // Registered explicitly so it is a known model rather than relying on the + // unknown-model fallback that coincidentally matches these rates. + 'claude-sonnet-5': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-sonnet-5-thinking': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, // Claude 4 Opus ($15/$75) 'claude-4-opus-20250514': { inputPerMillion: 15.0, diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 7502f674..9ce51248 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -277,6 +277,14 @@ describe('model-pricing', () => { expect(fable5.cacheReadPerMillion).toBe(1.0); }); + it('should return correct pricing for Claude Sonnet 5', () => { + const sonnet5 = getModelPricing('claude-sonnet-5'); + expect(sonnet5.inputPerMillion).toBe(3.0); + expect(sonnet5.outputPerMillion).toBe(15.0); + expect(sonnet5.cacheCreationPerMillion).toBe(3.75); + expect(sonnet5.cacheReadPerMillion).toBe(0.3); + }); + it('should not map unknown future model families onto known family pricing', () => { const fallback = getModelPricing('unknown-model-xyz'); @@ -398,6 +406,17 @@ describe('model-pricing', () => { expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0 }); + it('should calculate Claude Sonnet 5 cost including cache token rates', () => { + const usage: TokenUsage = { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + cacheReadTokens: 1_000_000, + }; + const cost = calculateCost(usage, 'claude-sonnet-5'); + expect(cost).toBe(22.05); // 3 + 15 + 3.75 + 0.3 + }); + it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { const usage: TokenUsage = { inputTokens: 1_000_000, @@ -426,6 +445,13 @@ describe('model-pricing', () => { const models = getKnownModels(); expect(models.some((m) => m.startsWith('glm-'))).toBe(true); }); + + it('should include Claude Sonnet 5 as an explicitly registered model', () => { + // Sonnet 5 must be a first-class known model rather than relying on the + // unknown-model fallback that happens to share Sonnet's $3/$15 rates. + const models = getKnownModels(); + expect(models).toContain('claude-sonnet-5'); + }); }); describe('hasCustomPricing', () => { @@ -434,6 +460,14 @@ describe('model-pricing', () => { expect(hasCustomPricing('glm-4.6')).toBe(true); }); + it('should treat Claude Sonnet 5 as a known model with explicit pricing', () => { + // Without an explicit registry entry Sonnet 5 falls through to + // UNKNOWN_MODEL_PRICING (which coincidentally matches Sonnet rates); + // register it so cost accounting is intentional, not accidental. + expect(hasCustomPricing('claude-sonnet-5')).toBe(true); + expect(hasCustomPricing('claude-sonnet-5-thinking')).toBe(true); + }); + it('should return true for deterministic qwen3-coder alias', () => { expect(hasCustomPricing('qwen3-coder')).toBe(true); }); From da6c99e3dfeae12c0a441ca09cbb838d0793e47e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Jul 2026 14:22:20 +0000 Subject: [PATCH 14/35] chore(release): 8.7.0-dev.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ceb9f668..6453cb21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.5", + "version": "8.7.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 7f369f75dce8dbb1d5dde9b63c51219e46614533 Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Fri, 10 Jul 2026 00:39:19 +0800 Subject: [PATCH 15/35] fix: claude model no get the 1m tag --- .../config/extended-context-config.ts | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 7c63c68c..1b55b784 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -13,9 +13,12 @@ import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { + ANTHROPIC_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, + hasExtendedContextSuffix, isNativeGeminiModel, + stripExtendedContextSuffix, stripModelConfigurationSuffixes, } from '../../shared/extended-context-utils'; @@ -73,20 +76,41 @@ export function applyExtendedContextConfig( provider: CLIProxyProvider, extendedContextOverride?: boolean ): void { - if (extendedContextOverride === false) { - Object.assign(envVars, applyExtendedContextPreferenceToAnthropicModels(envVars, false)); + if (extendedContextOverride === true || extendedContextOverride === false) { + Object.assign( + envVars, + applyExtendedContextPreferenceToAnthropicModels(envVars, extendedContextOverride, { + supportsExtendedContext: (modelId) => + shouldApplyExtendedContext( + provider, + stripModelConfigurationSuffixes(modelId), + extendedContextOverride + ), + }) + ); return; } - Object.assign( - envVars, - applyExtendedContextPreferenceToAnthropicModels(envVars, true, { - supportsExtendedContext: (modelId) => - shouldApplyExtendedContext( - provider, - stripModelConfigurationSuffixes(modelId), - extendedContextOverride - ), - }) - ); + // Auto mode (no explicit --1m/--no-1m this launch): don't force-strip a + // previously saved [1m] preference just because the model is Claude — only + // strip when the model no longer supports extended context. Native Gemini + // models still get auto-toggled based on catalog support. + for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + const value = envVars[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + continue; + } + const modelId = stripModelConfigurationSuffixes(value); + + if (isNativeGeminiModel(modelId)) { + envVars[key] = supportsExtendedContext(provider, modelId) + ? applyExtendedContextSuffixShared(value) + : value; + continue; + } + + if (hasExtendedContextSuffix(value) && !supportsExtendedContext(provider, modelId)) { + envVars[key] = stripExtendedContextSuffix(value); + } + } } From 77e73dd091c40fb2e9f91297b4030fb47ff1c08d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Jul 2026 08:54:18 -0400 Subject: [PATCH 16/35] fix(codex): preview joined migration TOML repair Closes #1635 --- src/shared/toml-object.ts | 127 ++++++++++++++ tests/unit/shared/toml-object.test.ts | 123 +++++++++++++ ui/src/pages/codex.tsx | 96 +++++++--- ui/tests/unit/ui/pages/codex-page.test.tsx | 194 ++++++++++++++++++++- 4 files changed, 513 insertions(+), 27 deletions(-) create mode 100644 tests/unit/shared/toml-object.test.ts diff --git a/src/shared/toml-object.ts b/src/shared/toml-object.ts index cefa4d62..018a4744 100644 --- a/src/shared/toml-object.ts +++ b/src/shared/toml-object.ts @@ -26,6 +26,75 @@ export function parseTomlObject(rawText: string): Record { return parsed; } +export interface TomlNewlineRepairProposal { + repairedText: string; + insertionOffset: number; +} + +const CODEX_MODEL_MIGRATION_TABLE = /^[ \t]*\[notice\.model_migrations\][ \t]*(?:#.*)?$/; +const QUOTED_MIGRATION_JOIN = + /^[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')[ \t]*=[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')(?=\[)/; +const TOML_TABLE_HEADER_SUFFIX = /^(?:\[\[[^\]\r\n]+\]\]|\[[^\]\r\n]+\])[ \t]*(?:#.*)?$/; + +type TomlMultilineDelimiter = '"""' | "'''"; + +function isEscapedAt(text: string, offset: number): boolean { + let backslashCount = 0; + for (let index = offset - 1; index >= 0 && text[index] === '\\'; index -= 1) { + backslashCount += 1; + } + return backslashCount % 2 === 1; +} + +function updateTomlMultilineDelimiter( + line: string, + initialDelimiter: TomlMultilineDelimiter | null +): TomlMultilineDelimiter | null { + let delimiter = initialDelimiter; + let cursor = 0; + + while (cursor < line.length) { + if (delimiter) { + const closingOffset = line.indexOf(delimiter, cursor); + if (closingOffset === -1) return delimiter; + if (delimiter === '"""' && isEscapedAt(line, closingOffset)) { + cursor = closingOffset + 1; + continue; + } + cursor = closingOffset + delimiter.length; + delimiter = null; + continue; + } + + const char = line[cursor]; + if (char === '#') return null; + if (line.startsWith('"""', cursor) || line.startsWith("'''", cursor)) { + delimiter = line.slice(cursor, cursor + 3) as TomlMultilineDelimiter; + cursor += 3; + continue; + } + if (char === '"') { + cursor += 1; + while (cursor < line.length) { + if (line[cursor] === '"' && !isEscapedAt(line, cursor)) { + cursor += 1; + break; + } + cursor += 1; + } + continue; + } + if (char === "'") { + const closingOffset = line.indexOf("'", cursor + 1); + cursor = closingOffset === -1 ? line.length : closingOffset + 1; + continue; + } + cursor += 1; + } + + return delimiter; +} + export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult { try { return { @@ -39,3 +108,61 @@ export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult }; } } + +export function proposeJoinedCodexModelMigrationRepair( + rawText: string +): TomlNewlineRepairProposal | null { + if (!safeParseTomlObject(rawText).parseError) return null; + + const candidates: Array<{ insertionOffset: number; lineEnding: string }> = []; + let cursor = 0; + let inModelMigrationTable = false; + let previousLineEnding = '\n'; + let multilineDelimiter: TomlMultilineDelimiter | null = null; + + while (cursor < rawText.length) { + const newlineOffset = rawText.indexOf('\n', cursor); + const rawLineEnd = newlineOffset === -1 ? rawText.length : newlineOffset; + const usesCrLf = newlineOffset !== -1 && rawText[rawLineEnd - 1] === '\r'; + const contentEnd = usesCrLf ? rawLineEnd - 1 : rawLineEnd; + const lineEnding = newlineOffset === -1 ? '' : usesCrLf ? '\r\n' : '\n'; + const rawLine = rawText.slice(cursor, contentEnd); + const line = cursor === 0 && rawLine.charCodeAt(0) === 0xfeff ? rawLine.slice(1) : rawLine; + + if (!multilineDelimiter) { + if (CODEX_MODEL_MIGRATION_TABLE.test(line)) { + inModelMigrationTable = true; + } else if (/^[ \t]*\[\[?/.test(line)) { + inModelMigrationTable = false; + } else if (inModelMigrationTable) { + const joinedMapping = line.match(QUOTED_MIGRATION_JOIN); + if (joinedMapping) { + const suffix = line.slice(joinedMapping[0].length); + if (TOML_TABLE_HEADER_SUFFIX.test(suffix)) { + candidates.push({ + insertionOffset: cursor + joinedMapping[0].length, + lineEnding: lineEnding || previousLineEnding, + }); + } + } + } + } + multilineDelimiter = updateTomlMultilineDelimiter(line, multilineDelimiter); + + if (lineEnding) previousLineEnding = lineEnding; + if (newlineOffset === -1) break; + cursor = newlineOffset + 1; + } + + if (candidates.length !== 1) return null; + + const [{ insertionOffset, lineEnding }] = candidates; + const repairedText = `${rawText.slice(0, insertionOffset)}${lineEnding}${rawText.slice(insertionOffset)}`; + + try { + parseTomlObject(repairedText); + return { repairedText, insertionOffset }; + } catch { + return null; + } +} diff --git a/tests/unit/shared/toml-object.test.ts b/tests/unit/shared/toml-object.test.ts new file mode 100644 index 00000000..2f7d624b --- /dev/null +++ b/tests/unit/shared/toml-object.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'bun:test'; + +import { + parseTomlObject, + proposeJoinedCodexModelMigrationRepair, +} from '../../../src/shared/toml-object'; + +describe('joined Codex model migration TOML recovery', () => { + it('proposes the exact one-newline repair for a joined following table header', () => { + const rawText = `model = "gpt-5.4" + +[notice.model_migrations] +"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier] +description = "Keep this exact text" +`; + + const proposal = proposeJoinedCodexModelMigrationRepair(rawText); + const insertionOffset = rawText.indexOf('[agents.code_simplifier]'); + + expect(proposal).toEqual({ + repairedText: `${rawText.slice(0, insertionOffset)}\n${rawText.slice(insertionOffset)}`, + insertionOffset, + }); + expect(() => parseTomlObject(proposal?.repairedText ?? '')).not.toThrow(); + }); + + it('preserves a UTF-8 BOM, CRLF endings, comments, spacing, and quoting', () => { + const rawText = + "\uFEFF# exact comment\r\n[notice.model_migrations]\r\n 'gpt-5.3-codex' = 'gpt-5.4'[[agents]]\r\nname = \"one\"\r\n"; + + const proposal = proposeJoinedCodexModelMigrationRepair(rawText); + const insertionOffset = rawText.indexOf('[[agents]]'); + + expect(proposal?.repairedText).toBe( + `${rawText.slice(0, insertionOffset)}\r\n${rawText.slice(insertionOffset)}` + ); + expect(proposal?.insertionOffset).toBe(insertionOffset); + }); + + it('does not propose a repair for valid TOML', () => { + const rawText = `[notice.model_migrations] +"gpt-5.3-codex" = "gpt-5.4" + +[agents.code_simplifier] +description = "valid" +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); + + it('does not generically repair a joined boundary outside the migration table', () => { + const rawText = `model = "gpt-5.4"[agents.code_simplifier] +description = "invalid" +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); + + it('ignores a migration table lookalike inside a multiline TOML string', () => { + const rawText = `description = """ +[notice.model_migrations] +""" +"old" = "new"[agents.outside] +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); + + it('does not propose a repair for unrelated invalid TOML', () => { + expect(proposeJoinedCodexModelMigrationRepair('model = "gpt-5.4"\n[features\n')).toBeNull(); + }); + + it('requires a quoted string-to-string migration mapping', () => { + const rawText = `[notice.model_migrations] +gpt-5 = 54[agents.code_simplifier] +description = "invalid" +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); + + it('does not propose a repair when the following text is not a valid table header', () => { + const rawText = `[notice.model_migrations] +"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); + + it('does not propose a repair when another syntax error would remain', () => { + const rawText = `[notice.model_migrations] +"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier] +description = "valid" +[features +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); + + it('does not propose an ambiguous repair when multiple joined boundaries match', () => { + const rawText = `[notice.model_migrations] +"gpt-5.2" = "gpt-5.3"[agents.first] +name = "first" + +[notice.model_migrations] +"gpt-5.3" = "gpt-5.4"[agents.second] +name = "second" +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); + + it('does not treat a joined-looking string inside a multiline value as a repair candidate', () => { + const rawText = `[notice.model_migrations] +note = """ +"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier] +""" +[features +`; + + expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull(); + }); +}); diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx index eb04fca3..0175daae 100644 --- a/ui/src/pages/codex.tsx +++ b/ui/src/pages/codex.tsx @@ -10,6 +10,7 @@ import { useCodex } from '@/hooks/use-codex'; import { isApiConflictError } from '@/lib/api-client'; import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab'; import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; +import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { getKnownCodexFeatures, @@ -20,7 +21,13 @@ import { readCodexProjectTrust, readCodexTopLevelSettings, } from '@/lib/codex-config'; -import { safeParseTomlObject } from '@shared/toml-object'; +import { proposeJoinedCodexModelMigrationRepair, safeParseTomlObject } from '@shared/toml-object'; + +interface RawConfigDraft { + text: string; + baseText: string; + expectedMtime?: number; +} export function CodexPage() { const { t } = useTranslation(); @@ -40,10 +47,14 @@ export function CodexPage() { isPatchingConfig, } = useCodex(); - const [rawDraftText, setRawDraftText] = useState(null); + const [rawDraft, setRawDraft] = useState(null); const rawBaseText = rawConfig?.rawText ?? ''; - const rawEditorText = rawDraftText ?? rawBaseText; - const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; + const rawEditorText = rawDraft?.text ?? rawBaseText; + const rawConfigDirty = rawDraft !== null; + const rawBaseRepairProposal = useMemo(() => { + if (rawConfigDirty || !rawConfig?.parseError || rawConfig.readError) return null; + return proposeJoinedCodexModelMigrationRepair(rawBaseText); + }, [rawBaseText, rawConfig?.parseError, rawConfig?.readError, rawConfigDirty]); const rawEditorParsed = safeParseTomlObject(rawEditorText); const rawEditorValidation = rawEditorParsed.parseError ? { valid: false as const, error: rawEditorParsed.parseError } @@ -84,11 +95,20 @@ export function CodexPage() { const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]); const setRawEditorDraftText = (nextText: string) => { - if (nextText === rawBaseText) { - setRawDraftText(null); - return; - } - setRawDraftText(nextText); + setRawDraft((currentDraft) => { + const baseText = currentDraft?.baseText ?? rawBaseText; + if (nextText === baseText) return null; + + return { + text: nextText, + baseText, + expectedMtime: currentDraft + ? currentDraft.expectedMtime + : rawConfig?.exists + ? rawConfig.mtime + : undefined, + }; + }); }; const refreshAll = async () => { @@ -103,24 +123,24 @@ export function CodexPage() { return; } - setRawDraftText(null); + setRawDraft(null); } catch (error) { toast.error((error as Error).message || t('toasts.codexRefreshError')); } }; const handleSaveRawConfig = async () => { - if (!rawEditorValidation.valid) { + if (!rawDraft || !rawEditorValidation.valid) { toast.error(t('toasts.codexFixToml')); return; } try { await saveRawConfigAsync({ - rawText: rawEditorText, - expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined, + rawText: rawDraft.text, + expectedMtime: rawDraft.expectedMtime, }); - setRawDraftText(null); + setRawDraft(null); toast.success(t('toasts.codexSaved')); await refetchDiagnostics(); } catch (error) { @@ -141,7 +161,7 @@ export function CodexPage() { ...patch, expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined, }); - setRawDraftText(null); + setRawDraft(null); toast.success(successMessage); } catch (error) { if (isApiConflictError(error)) { @@ -259,7 +279,7 @@ export function CodexPage() { }} onSave={handleSaveRawConfig} onRefresh={refreshAll} - onDiscard={() => setRawDraftText(null)} + onDiscard={() => setRawDraft(null)} language="toml" exactText /* TODO i18n: missing key for "Loading config.toml..." */ @@ -267,16 +287,40 @@ export function CodexPage() { /* TODO i18n: missing key for "TOML warning" */ parseWarningLabel="TOML warning" ownershipNotice={ -
- {/* TODO i18n: missing keys for ownership notice paragraphs */} -

This file is upstream-owned by Codex CLI.

-

- CCS does not keep ~/.codex/config.toml in sync for you. -

-

- CCS-backed Codex launches may apply transient -c overrides and - CCS_CODEX_API_KEY; those effective values may not appear here. -

+
+ {rawBaseRepairProposal ? ( +
+

Safe repair available

+

+ Codex joined a model migration to the next table header. Previewing inserts + exactly one newline in this unsaved editor draft. Review it, then select Save + to write config.toml. +

+ +
+ ) : null} +
+ {/* TODO i18n: missing keys for ownership notice paragraphs */} +

This file is upstream-owned by Codex CLI.

+

+ CCS does not keep ~/.codex/config.toml in sync for you. +

+

+ CCS-backed Codex launches may apply transient -c overrides and + CCS_CODEX_API_KEY; those effective values may not appear here. +

+
} /> diff --git a/ui/tests/unit/ui/pages/codex-page.test.tsx b/ui/tests/unit/ui/pages/codex-page.test.tsx index cae3fa89..1b450456 100644 --- a/ui/tests/unit/ui/pages/codex-page.test.tsx +++ b/ui/tests/unit/ui/pages/codex-page.test.tsx @@ -40,7 +40,11 @@ vi.mock('@/components/shared/code-editor', () => ({ })); vi.mock('@/components/compatible-cli/codex-control-center-tab', () => ({ - CodexControlCenterTab: () =>
Control Center
, + CodexControlCenterTab: ({ disabled }: { disabled?: boolean }) => ( +
+ Control Center +
+ ), })); vi.mock('@/components/compatible-cli/codex-docs-tab', () => ({ @@ -53,6 +57,15 @@ vi.mock('@/components/compatible-cli/codex-overview-tab', () => ({ import { CodexPage } from '@/pages/codex'; +const joinedMigrationRawText = `[notice.model_migrations] +"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier] +description = "Keep exact text" +`; +const repairedMigrationRawText = joinedMigrationRawText.replace( + '"gpt-5.4"[agents.code_simplifier]', + '"gpt-5.4"\n[agents.code_simplifier]' +); + const diagnostics = { binary: { installed: true, @@ -228,5 +241,184 @@ describe('CodexPage', () => { expect(screen.getByText(/Read-only: Refusing symlink file for safety\./)).toBeInTheDocument(); expect(screen.getByLabelText('codex raw editor')).toHaveAttribute('readonly'); + expect( + screen.queryByRole('button', { name: 'Preview one-newline repair' }) + ).not.toBeInTheDocument(); + }); + + it('previews the one-newline repair as an unsaved draft before explicitly saving it', async () => { + mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 }); + mocks.useCodex.mockReturnValue( + buildUseCodexResult({ + rawConfig: { + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + mtime: 100, + rawText: joinedMigrationRawText, + config: null, + parseError: 'Invalid TOML', + readError: null, + }, + }) + ); + + render(); + + await userEvent.click(screen.getByRole('tab', { name: 'Control Center' })); + expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true'); + await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' })); + + expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText); + expect(screen.getByText('Unsaved')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true'); + expect(mocks.saveRawConfigAsync).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({ + rawText: repairedMigrationRawText, + expectedMtime: 100, + }) + ); + }); + + it('keeps the repair draft bound to its original mtime across a fetched snapshot update', async () => { + mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 }); + let rawConfig = { + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + mtime: 100, + rawText: joinedMigrationRawText, + config: null, + parseError: 'Invalid TOML', + readError: null, + }; + mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig })); + + const view = render(); + await userEvent.click(screen.getByRole('tab', { name: 'Control Center' })); + await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' })); + + rawConfig = { + ...rawConfig, + mtime: 200, + rawText: 'model = "gpt-5.4-mini"\n', + config: { model: 'gpt-5.4-mini' }, + parseError: null, + }; + view.rerender(); + + expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText); + expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true'); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({ + rawText: repairedMigrationRawText, + expectedMtime: 100, + }) + ); + }); + + it('keeps manual raw edits bound to the snapshot mtime captured on first edit', async () => { + mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 }); + let rawConfig = { + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + mtime: 100, + rawText: 'model = "gpt-5.4"\n', + config: { model: 'gpt-5.4' }, + parseError: null, + readError: null, + }; + mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig })); + + const view = render(); + const editor = screen.getByLabelText('codex raw editor'); + await userEvent.clear(editor); + await userEvent.type(editor, 'model = "gpt-5.4-mini"'); + + rawConfig = { + ...rawConfig, + mtime: 200, + rawText: 'model = "gpt-5.4-high"\n', + config: { model: 'gpt-5.4-high' }, + }; + view.rerender(); + + expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4-mini"'); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({ + rawText: 'model = "gpt-5.4-mini"', + expectedMtime: 100, + }) + ); + }); + + it('does not offer the targeted repair for generic invalid TOML', () => { + mocks.useCodex.mockReturnValue( + buildUseCodexResult({ + rawConfig: { + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + mtime: 100, + rawText: 'model = "gpt-5.4"\n[features\n', + config: null, + parseError: 'Invalid TOML', + readError: null, + }, + }) + ); + + render(); + + expect( + screen.queryByRole('button', { name: 'Preview one-newline repair' }) + ).not.toBeInTheDocument(); + }); + + it('re-enables structured controls only after the saved valid snapshot is loaded', async () => { + mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 }); + let rawConfig = { + path: '$CODEX_HOME/config.toml', + resolvedPath: '/tmp/.codex/config.toml', + exists: true, + mtime: 100, + rawText: joinedMigrationRawText, + config: null, + parseError: 'Invalid TOML', + readError: null, + }; + mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig })); + + const view = render(); + await userEvent.click(screen.getByRole('tab', { name: 'Control Center' })); + await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' })); + expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true'); + + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(mocks.saveRawConfigAsync).toHaveBeenCalledTimes(1)); + + rawConfig = { + ...rawConfig, + mtime: 101, + rawText: repairedMigrationRawText, + config: { + notice: { model_migrations: { 'gpt-5.3-codex': 'gpt-5.4' } }, + agents: { code_simplifier: { description: 'Keep exact text' } }, + }, + parseError: null, + }; + view.rerender(); + + expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'false'); }); }); From 24eae8ae8f0dec38e0e12b8bb1147ebc1e8a85ed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Jul 2026 08:54:48 -0400 Subject: [PATCH 17/35] feat(codex): add GPT-5.6 model catalog Closes #1633 --- src/cliproxy/__tests__/model-catalog.test.js | 3 ++ .../codex-plan-compatibility.test.ts | 6 +++ src/cliproxy/model-catalog.ts | 36 ++++++++++++++++ .../cliproxy/provider-model-selector.tsx | 8 +++- ui/src/lib/codex-effort.ts | 7 ++-- ui/src/lib/model-catalogs.ts | 42 +++++++++++++++++++ .../cliproxy/provider-model-selector.test.tsx | 4 ++ .../unit/ui/lib/model-catalogs-codex.test.ts | 9 ++++ 8 files changed, 111 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 32f4cecd..34d0a133 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -247,6 +247,9 @@ describe('Model Catalog', () => { const { MODEL_CATALOG } = modelCatalog; const ids = MODEL_CATALOG.codex.models.map((m) => m.id); assert.deepStrictEqual(ids, [ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', diff --git a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts index 45548c1d..932feeee 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts @@ -29,6 +29,9 @@ describe('codex plan compatibility', () => { }); it('does not rewrite cross-plan or already-safe Codex models', () => { + expect(getFreePlanFallbackCodexModel('gpt-5.6-sol')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.6-terra-high')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.6-luna-fast')).toBeNull(); expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBeNull(); expect(getFreePlanFallbackCodexModel('gpt-5.4-mini')).toBeNull(); expect(getFreePlanFallbackCodexModel('gpt-5.2')).toBeNull(); @@ -94,6 +97,9 @@ describe('codex plan compatibility', () => { }); it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => { + expect(getModelMaxLevel('codex', 'gpt-5.6-sol')).toBe('xhigh'); + expect(getModelMaxLevel('codex', 'gpt-5.6-terra')).toBe('xhigh'); + expect(getModelMaxLevel('codex', 'gpt-5.6-luna')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.4-mini')).toBe('high'); diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 8ba19731..94cf22b6 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -190,6 +190,42 @@ export const MODEL_CATALOG: Partial> = displayName: 'Copilot Codex', defaultModel: 'gpt-5.4', models: [ + { + id: 'gpt-5.6-sol', + name: 'GPT-5.6 Sol', + description: 'Latest frontier agentic coding model.', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + codexServiceTiers: ['fast'], + }, + { + id: 'gpt-5.6-terra', + name: 'GPT-5.6 Terra', + description: 'Balanced agentic coding model for everyday work.', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + codexServiceTiers: ['fast'], + }, + { + id: 'gpt-5.6-luna', + name: 'GPT-5.6 Luna', + description: 'Fast and affordable agentic coding model.', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + codexServiceTiers: ['fast'], + }, { id: 'gpt-5.5', name: 'GPT-5.5', diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index aa644703..a9c47779 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -43,6 +43,8 @@ export interface ModelEntry { }; /** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */ codexMaxEffort?: CodexEffort; + /** Exact Codex reasoning-effort suffixes supported by this model in the dashboard UI. */ + codexEfforts?: CodexEffort[]; /** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */ codexServiceTiers?: CodexServiceTier[]; } @@ -347,12 +349,13 @@ function getPreferredOptionValue( function getModelOptionValues( codexMaxEffort: CodexEffort | undefined, + codexEfforts: readonly CodexEffort[] | undefined, codexServiceTiers: readonly CodexServiceTier[] | undefined, optionValue: string, isCodexProvider: boolean ): string[] { return isCodexProvider - ? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers) + ? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers, codexEfforts) : [optionValue]; } @@ -395,6 +398,7 @@ export function FlexibleModelSelector({ resolvedCatalogModels.flatMap((model) => getModelOptionValues( model.codexMaxEffort, + model.codexEfforts, model.codexServiceTiers, getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), isCodexProvider @@ -431,6 +435,7 @@ export function FlexibleModelSelector({ const routingHint = routingHints.get(model.id.toLowerCase()); const optionValues = getModelOptionValues( model.codexMaxEffort, + model.codexEfforts, model.codexServiceTiers, getPreferredOptionValue(model.id, routingHint), isCodexProvider @@ -485,6 +490,7 @@ export function FlexibleModelSelector({ .flatMap((model) => { const routingHint = routingHints.get(model.id.toLowerCase()); const optionValues = getModelOptionValues( + undefined, undefined, undefined, getPreferredOptionValue(model.id, routingHint), diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index 7da7355b..ef884b0d 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -69,7 +69,8 @@ export function applyCodexEffortSuffix( export function getCodexEffortVariants( modelId: string, maxEffort: CodexEffort | undefined, - serviceTiers: readonly CodexServiceTier[] = [] + serviceTiers: readonly CodexServiceTier[] = [], + efforts?: readonly CodexEffort[] ): string[] { if (!maxEffort) { const explicitEffort = parseCodexEffort(modelId); @@ -91,9 +92,9 @@ export function getCodexEffortVariants( appendVariantsForEffort(undefined); - for (const effort of CODEX_EFFORTS_IN_ORDER) { + for (const effort of efforts ?? CODEX_EFFORTS_IN_ORDER) { appendVariantsForEffort(effort); - if (effort === maxEffort) { + if (!efforts && effort === maxEffort) { break; } } diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 1d358ee7..2a6475ee 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -259,6 +259,48 @@ export const MODEL_CATALOGS: Record = { displayName: 'Codex', defaultModel: 'gpt-5.4', models: [ + { + id: 'gpt-5.6-sol', + name: 'GPT-5.6 Sol', + description: 'Latest frontier agentic coding model.', + codexMaxEffort: 'xhigh', + codexEfforts: ['low', 'medium', 'high', 'xhigh'], + codexServiceTiers: ['fast'], + presetMapping: { + default: 'gpt-5.6-sol', + opus: 'gpt-5.6-sol', + sonnet: 'gpt-5.6-sol', + haiku: 'gpt-5.4-mini', + }, + }, + { + id: 'gpt-5.6-terra', + name: 'GPT-5.6 Terra', + description: 'Balanced agentic coding model for everyday work.', + codexMaxEffort: 'xhigh', + codexEfforts: ['low', 'medium', 'high', 'xhigh'], + codexServiceTiers: ['fast'], + presetMapping: { + default: 'gpt-5.6-terra', + opus: 'gpt-5.6-terra', + sonnet: 'gpt-5.6-terra', + haiku: 'gpt-5.4-mini', + }, + }, + { + id: 'gpt-5.6-luna', + name: 'GPT-5.6 Luna', + description: 'Fast and affordable agentic coding model.', + codexMaxEffort: 'xhigh', + codexEfforts: ['low', 'medium', 'high', 'xhigh'], + codexServiceTiers: ['fast'], + presetMapping: { + default: 'gpt-5.6-luna', + opus: 'gpt-5.6-luna', + sonnet: 'gpt-5.6-luna', + haiku: 'gpt-5.4-mini', + }, + }, { id: 'gpt-5.5', name: 'GPT-5.5', diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index 4c71ee7b..c1c29da3 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -191,6 +191,10 @@ describe('FlexibleModelSelector', () => { expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument(); expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument(); expect(screen.getByText('gpt-5.4-high-fast')).toBeInTheDocument(); + expect(screen.getByText('gpt-5.6-sol-low')).toBeInTheDocument(); + expect(screen.getByText('gpt-5.6-terra-xhigh')).toBeInTheDocument(); + expect(screen.getByText('gpt-5.6-luna-fast')).toBeInTheDocument(); + expect(screen.queryByText('gpt-5.6-sol-minimal')).not.toBeInTheDocument(); await userEvent.click(screen.getByText('gpt-5.3-codex-high')); expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high'); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts index 150de1c4..6c37a5ae 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -4,6 +4,9 @@ import { MODEL_CATALOGS } from '@/lib/model-catalogs'; describe('codex model catalog defaults', () => { it('mirrors the current Codex runtime catalog and free-safe defaults', () => { const codexCatalog = MODEL_CATALOGS.codex; + const codex56Sol = codexCatalog.models.find((model) => model.id === 'gpt-5.6-sol'); + const codex56Terra = codexCatalog.models.find((model) => model.id === 'gpt-5.6-terra'); + const codex56Luna = codexCatalog.models.find((model) => model.id === 'gpt-5.6-luna'); const codex55 = codexCatalog.models.find((model) => model.id === 'gpt-5.5'); const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex'); const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2'); @@ -11,6 +14,12 @@ describe('codex model catalog defaults', () => { const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5.4-mini'); expect(codexCatalog.defaultModel).toBe('gpt-5.4'); + for (const model of [codex56Sol, codex56Terra, codex56Luna]) { + expect(model?.tier).toBeUndefined(); + expect(model?.codexEfforts).toEqual(['low', 'medium', 'high', 'xhigh']); + expect(model?.codexServiceTiers).toEqual(['fast']); + expect(model?.presetMapping?.haiku).toBe('gpt-5.4-mini'); + } expect(codex55?.tier).toBe('paid'); expect(codex55?.presetMapping?.haiku).toBe('gpt-5.4-mini'); expect(codex55?.codexMaxEffort).toBe('xhigh'); From 507ce278b960d47955b4ff71a124037615126cbf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Jul 2026 17:30:09 +0000 Subject: [PATCH 18/35] chore(release): 8.7.0-dev.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6453cb21..2effe240 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.6", + "version": "8.7.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From a7f3e4bc0eebadfaa62c87306855ec19526bf08b Mon Sep 17 00:00:00 2001 From: Tom Price Date: Sat, 11 Jul 2026 13:38:45 -0400 Subject: [PATCH 19/35] fix: guard against missing modelBreakdowns in usage aggregation The CCS Bar server was crash-looping with: [X] Cannot read properties of undefined (reading 'length') Usage records from partially-written or legacy persisted files (e.g. a Codex rollout scanned mid-write) can be missing modelBreakdowns despite the type. getHourlyRequestCount() and the daily/monthly/hourly merges read it unguarded, and the Bar re-runs aggregation every ~5 min and restarts on crash - hence the loop. Default modelBreakdowns to [] at every use site and fall back to 0 for request count. Regression test added for all three merges. I don't normally work in TS and this was heavily tool-assisted, but it has fixed the crash loop on my machine. --- src/web-server/usage/aggregator.ts | 17 +-- .../usage-aggregator-malformed-record.test.ts | 106 ++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 tests/unit/web-server/usage-aggregator-malformed-record.test.ts diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 329fa90a..91645ded 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -113,7 +113,8 @@ async function loadInstanceData(instancePath: string): Promise<{ } function getHourlyRequestCount(hour: HourlyUsage): number { - return hour.requestCount ?? hour.modelBreakdowns.length; + // modelBreakdowns can be absent at runtime (legacy snapshots, files read mid-write). + return hour.requestCount ?? hour.modelBreakdowns?.length ?? 0; } function finalizeDailyUsage(day: DailyUsage): DailyUsage { @@ -165,7 +166,7 @@ export function mergeDailyData( existing.cacheReadTokens += day.cacheReadTokens; existing.totalCost += day.totalCost; // Merge model breakdowns by aggregating same modelName - for (const breakdown of day.modelBreakdowns) { + for (const breakdown of day.modelBreakdowns ?? []) { const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( (b) => getProviderModelKey(b) === breakdownKey @@ -182,7 +183,7 @@ export function mergeDailyData( } } else { // Clone to avoid mutating original - const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b })); + const modelBreakdowns = (day.modelBreakdowns ?? []).map((b) => ({ ...b })); dateMap.set(mergeKey, { ...day, ...(options.preserveProfile && day.profile ? { profile: day.profile } : {}), @@ -219,7 +220,7 @@ export function mergeMonthlyData( existing.cacheCreationTokens += month.cacheCreationTokens; existing.cacheReadTokens += month.cacheReadTokens; existing.totalCost += month.totalCost; - for (const breakdown of month.modelBreakdowns) { + for (const breakdown of month.modelBreakdowns ?? []) { const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( (item) => getProviderModelKey(item) === breakdownKey @@ -235,7 +236,9 @@ export function mergeMonthlyData( } } } else { - const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown })); + const modelBreakdowns = (month.modelBreakdowns ?? []).map((breakdown) => ({ + ...breakdown, + })); monthMap.set(mergeKey, { ...month, ...(options.preserveProfile && month.profile ? { profile: month.profile } : {}), @@ -275,7 +278,7 @@ export function mergeHourlyData( existing.totalCost += hour.totalCost; existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour); // Merge model breakdowns - for (const breakdown of hour.modelBreakdowns) { + for (const breakdown of hour.modelBreakdowns ?? []) { const breakdownKey = getProviderModelKey(breakdown); const existingBreakdown = existing.modelBreakdowns.find( (b) => getProviderModelKey(b) === breakdownKey @@ -291,7 +294,7 @@ export function mergeHourlyData( } } } else { - const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b })); + const modelBreakdowns = (hour.modelBreakdowns ?? []).map((b) => ({ ...b })); hourMap.set(mergeKey, { ...hour, ...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}), diff --git a/tests/unit/web-server/usage-aggregator-malformed-record.test.ts b/tests/unit/web-server/usage-aggregator-malformed-record.test.ts new file mode 100644 index 00000000..1c5e189a --- /dev/null +++ b/tests/unit/web-server/usage-aggregator-malformed-record.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'bun:test'; +import { + mergeDailyData, + mergeHourlyData, + mergeMonthlyData, +} from '../../../src/web-server/usage/aggregator'; +import type { + DailyUsage, + HourlyUsage, + ModelBreakdown, + MonthlyUsage, +} from '../../../src/web-server/usage/types'; + +const breakdown: ModelBreakdown = { + modelName: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.1, +}; + +function validHour(): HourlyUsage { + return { + hour: '2026-03-02 10:00', + source: 'test', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.1, + totalCost: 0.1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [{ ...breakdown }], + requestCount: 1, + }; +} + +function validDay(): DailyUsage { + return { + date: '2026-03-02', + source: 'test', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.1, + totalCost: 0.1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [{ ...breakdown }], + }; +} + +function validMonth(): MonthlyUsage { + return { + month: '2026-03', + source: 'test', + inputTokens: 100, + outputTokens: 40, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0.1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [{ ...breakdown }], + }; +} + +/** + * Simulates a partially-written or legacy persisted usage record that is + * missing `modelBreakdowns` (and `requestCount`) despite the type declaring + * them, e.g. a Codex rollout file scanned mid-write. + */ +function stripBreakdowns(record: T): T { + const partial = { ...record } as Partial; + delete partial.modelBreakdowns; + if ('requestCount' in partial) { + delete (partial as { requestCount?: number }).requestCount; + } + return partial as T; +} + +describe('usage aggregator merge with missing modelBreakdowns', () => { + it('does not throw when a same-hour record is missing modelBreakdowns (merge-into-existing path)', () => { + const merged = mergeHourlyData([[validHour()], [stripBreakdowns(validHour())]]); + expect(merged).toHaveLength(1); + // requestCount stays numeric instead of throwing on `.length` of undefined. + expect(typeof merged[0]?.requestCount).toBe('number'); + // Tokens from both records still aggregate. + expect(merged[0]?.inputTokens).toBe(200); + }); + + it('does not throw when the only record is missing modelBreakdowns (new-bucket path)', () => { + const merged = mergeHourlyData([[stripBreakdowns(validHour())]]); + expect(merged).toHaveLength(1); + expect(merged[0]?.modelBreakdowns).toEqual([]); + expect(merged[0]?.requestCount).toBe(0); + }); + + it('daily merge tolerates a record missing modelBreakdowns', () => { + expect(() => mergeDailyData([[validDay()], [stripBreakdowns(validDay())]])).not.toThrow(); + }); + + it('monthly merge tolerates a record missing modelBreakdowns', () => { + expect(() => mergeMonthlyData([[validMonth()], [stripBreakdowns(validMonth())]])).not.toThrow(); + }); +}); From 5943c8fa3cc16814e7f3cd44279f1a11be3b3609 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 13 Jul 2026 09:14:04 -0400 Subject: [PATCH 20/35] fix: strip stale Gemini extended context suffixes --- .../__tests__/extended-context-config.test.ts | 20 +++++++++++++++++++ .../config/extended-context-config.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/ai-providers/__tests__/extended-context-config.test.ts b/src/cliproxy/ai-providers/__tests__/extended-context-config.test.ts index 9d241a3a..33e84ed2 100644 --- a/src/cliproxy/ai-providers/__tests__/extended-context-config.test.ts +++ b/src/cliproxy/ai-providers/__tests__/extended-context-config.test.ts @@ -168,6 +168,26 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]'); }); + it('preserves a saved [1m] preference for supported Claude models in auto mode', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929[1m]', + }; + + applyExtendedContextConfig(env, 'claude', undefined); + + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929[1m]'); + }); + + it('strips a stale [1m] preference from unsupported native Gemini models', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-flash[1m]', + }; + + applyExtendedContextConfig(env, 'gemini', undefined); + + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-flash'); + }); + it('handles empty env vars gracefully', () => { const env: NodeJS.ProcessEnv = {}; applyExtendedContextConfig(env, 'gemini', undefined); diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 1b55b784..98115888 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -105,7 +105,7 @@ export function applyExtendedContextConfig( if (isNativeGeminiModel(modelId)) { envVars[key] = supportsExtendedContext(provider, modelId) ? applyExtendedContextSuffixShared(value) - : value; + : stripExtendedContextSuffix(value); continue; } From 612a66425950d7ff2aa3a49b5f52afb44660b060 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 13:37:36 +0000 Subject: [PATCH 21/35] chore(release): 8.7.0-dev.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2effe240..b0e156a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.7", + "version": "8.7.0-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 463a1ffa50c473db443bd8d789746455e2e3ca28 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 09:44:06 -0400 Subject: [PATCH 22/35] chore(release): 8.7.0-dev.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b0e156a1..4ae74f41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.8", + "version": "8.7.0-dev.9", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From e5d4d860b32e8f8ff97d2da9a76530c99bfb81a0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 10:21:47 -0400 Subject: [PATCH 23/35] fix(codex-auth): share plugin cache across profiles --- src/codex-auth/codex-profile-plugin-cache.ts | 219 ++++++++++ src/codex-auth/codex-profile-resources.ts | 3 + .../codex-profile-resources.test.ts | 398 +++++++++++++++++- 3 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 src/codex-auth/codex-profile-plugin-cache.ts diff --git a/src/codex-auth/codex-profile-plugin-cache.ts b/src/codex-auth/codex-profile-plugin-cache.ts new file mode 100644 index 00000000..432f41fa --- /dev/null +++ b/src/codex-auth/codex-profile-plugin-cache.ts @@ -0,0 +1,219 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ConfigError } from '../errors/error-types'; +import { symlinkPointsTo } from '../management/shared-manager/fs-helpers'; +import { createLogger } from '../services/logging'; + +const logger = createLogger('codex-auth:resources'); +const FALLBACK_SAFE_SYMLINK_ERRORS = new Set(['EPERM', 'EACCES', 'ENOSYS']); + +export function ensureSharedPluginCache(profileDir: string, sharedCodexHome: string): void { + const targetPath = path.join(sharedCodexHome, 'plugins', 'cache'); + const pluginsPath = path.join(profileDir, 'plugins'); + const linkPath = path.join(pluginsPath, 'cache'); + + ensureProfileLocalPluginsDirectory(pluginsPath); + fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); + + const existingStat = lstatIfExists(linkPath); + if (isExpectedCacheLink(linkPath, targetPath, existingStat)) { + return; + } + + let backupPath = existingStat === null ? null : createPluginCacheBackupPath(pluginsPath); + if (backupPath !== null) { + try { + fs.renameSync(linkPath, backupPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + backupPath = null; + } + } + + try { + fs.symlinkSync(targetPath, linkPath, 'dir'); + } catch (err) { + if (isExpectedCacheLink(linkPath, targetPath)) { + removePluginCacheBackup(backupPath); + return; + } + + const pathAfterFailure = lstatIfExists(linkPath); + if (backupPath !== null && pathAfterFailure === null) { + if (restorePluginCacheWithoutOverwrite(linkPath, backupPath)) { + backupPath = null; + } + } + + const code = (err as NodeJS.ErrnoException | null)?.code; + if (!code || !FALLBACK_SAFE_SYMLINK_ERRORS.has(code)) { + throw err; + } + + if (isExpectedCacheLink(linkPath, targetPath)) { + removePluginCacheBackup(backupPath); + return; + } + const fallbackPathStat = lstatIfExists(linkPath); + if (backupPath !== null && fallbackPathStat?.isDirectory()) { + mergeMissingResourceTree(backupPath, linkPath); + removePluginCacheBackup(backupPath); + } + + ensureLocalPluginCacheDirectory(linkPath); + mergeMissingResourceTree(targetPath, linkPath); + process.stderr.write( + `[!] codex-auth: symlink unavailable; using profile-local plugin cache at ${linkPath}. ` + + `Copied missing shared entries; plugin cache updates won't propagate automatically.\n` + ); + logger.warn( + 'codex-auth.plugin-cache-copy-fallback', + 'Copied shared plugin cache after symlink failure', + { + link: linkPath, + target: targetPath, + error: err instanceof Error ? err.message : String(err), + } + ); + return; + } + + removePluginCacheBackup(backupPath); + + logger.stage( + 'dispatch', + 'codex.plugin-cache.symlink.created', + 'Created shared plugin cache symlink', + { + link: linkPath, + target: targetPath, + } + ); +} + +function ensureProfileLocalPluginsDirectory(pluginsPath: string): void { + const pluginsStat = lstatIfExists(pluginsPath); + if (pluginsStat === null) { + fs.mkdirSync(pluginsPath, { recursive: true, mode: 0o700 }); + return; + } + if (!pluginsStat.isDirectory()) { + throw new ConfigError( + 'Refusing plugin cache repair: profile plugins path is not a local directory.' + ); + } +} + +function isExpectedCacheLink( + linkPath: string, + targetPath: string, + stat: fs.Stats | null = lstatIfExists(linkPath) +): boolean { + if (!stat?.isSymbolicLink()) { + return false; + } + return symlinkPointsTo(linkPath, targetPath); +} + +function lstatIfExists(resourcePath: string): fs.Stats | null { + try { + return fs.lstatSync(resourcePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } +} + +function createPluginCacheBackupPath(pluginsPath: string): string { + return path.join( + pluginsPath, + `.cache.ccs-backup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); +} + +function removePluginCacheBackup(backupPath: string | null): void { + if (backupPath !== null) { + fs.rmSync(backupPath, { recursive: true, force: true }); + } +} + +function ensureLocalPluginCacheDirectory(linkPath: string): void { + let existingStat = lstatIfExists(linkPath); + if (existingStat?.isDirectory()) { + return; + } + if (existingStat !== null) { + throw new ConfigError( + 'Refusing plugin cache fallback: profile cache path is not a local directory.' + ); + } + + try { + fs.mkdirSync(linkPath, { recursive: false, mode: 0o700 }); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err; + } + } + + existingStat = lstatIfExists(linkPath); + if (!existingStat?.isDirectory()) { + throw new ConfigError( + 'Refusing plugin cache fallback: profile cache path is not a local directory.' + ); + } +} + +function restorePluginCacheWithoutOverwrite(linkPath: string, backupPath: string): boolean { + const backupStat = lstatIfExists(backupPath); + if (!backupStat?.isDirectory()) { + return false; + } + + let linkStat = lstatIfExists(linkPath); + if (linkStat === null) { + try { + fs.mkdirSync(linkPath, { recursive: false, mode: 0o700 }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err; + } + } + linkStat = lstatIfExists(linkPath); + } + + if (!linkStat?.isDirectory()) { + return false; + } + + mergeMissingResourceTree(backupPath, linkPath); + removePluginCacheBackup(backupPath); + return true; +} + +function mergeMissingResourceTree(sourceDir: string, targetDir: string): void { + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + const targetStat = lstatIfExists(targetPath); + + if (targetStat === null) { + fs.cpSync(sourcePath, targetPath, { + recursive: true, + force: false, + errorOnExist: false, + preserveTimestamps: true, + }); + continue; + } + + if (entry.isDirectory() && targetStat.isDirectory()) { + mergeMissingResourceTree(sourcePath, targetPath); + } + } +} diff --git a/src/codex-auth/codex-profile-resources.ts b/src/codex-auth/codex-profile-resources.ts index 7e230ae5..c552cac7 100644 --- a/src/codex-auth/codex-profile-resources.ts +++ b/src/codex-auth/codex-profile-resources.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { createLogger } from '../services/logging'; +import { ensureSharedPluginCache } from './codex-profile-plugin-cache'; const logger = createLogger('codex-auth:resources'); @@ -23,6 +24,8 @@ export function ensureCodexProfileResources( for (const resourceName of SHARED_CODEX_RESOURCE_DIRS) { ensureSharedResourceDir(profileDir, sharedCodexHome, resourceName); } + + ensureSharedPluginCache(profileDir, sharedCodexHome); } function ensureSharedResourceDir( diff --git a/tests/unit/codex-auth/codex-profile-resources.test.ts b/tests/unit/codex-auth/codex-profile-resources.test.ts index 506a9207..215bf248 100644 --- a/tests/unit/codex-auth/codex-profile-resources.test.ts +++ b/tests/unit/codex-auth/codex-profile-resources.test.ts @@ -6,6 +6,22 @@ import * as path from 'path'; let tempDir: string; let profileDir: string; let sharedCodexHome: string; +const cachedSkillRelativePath = path.join( + 'openai-bundled', + 'sites', + '0.1.27', + 'skills', + 'site-builder', + 'SKILL.md' +); +const staleCachedSkillRelativePath = path.join( + 'openai-bundled', + 'sites', + '0.1.26', + 'skills', + 'site-builder', + 'SKILL.md' +); beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resources-test-')); @@ -18,6 +34,17 @@ beforeEach(() => { ); fs.mkdirSync(path.join(sharedCodexHome, 'skills'), { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(sharedCodexHome, 'skills', 'review.md'), '# Review\n'); + fs.mkdirSync( + path.join(sharedCodexHome, 'plugins', 'cache', path.dirname(cachedSkillRelativePath)), + { + recursive: true, + mode: 0o700, + } + ); + fs.writeFileSync( + path.join(sharedCodexHome, 'plugins', 'cache', cachedSkillRelativePath), + '# Current shared skill\n' + ); }); afterEach(() => { @@ -26,7 +53,7 @@ afterEach(() => { }); describe('ensureCodexProfileResources', () => { - it('links shared agents and skills into a fresh Codex profile', async () => { + it('exposes shared agents, skills, and plugin cache in a fresh Codex profile', async () => { const { ensureCodexProfileResources } = await import( '../../../src/codex-auth/codex-profile-resources' ); @@ -38,6 +65,291 @@ describe('ensureCodexProfileResources', () => { expect(fs.lstatSync(resourcePath).isSymbolicLink()).toBe(true); expect(fs.readlinkSync(resourcePath)).toBe(path.join(sharedCodexHome, resourceName)); } + + const cachedSkillPath = path.join(profileDir, 'plugins', 'cache', cachedSkillRelativePath); + expect(fs.readFileSync(cachedSkillPath, 'utf8')).toBe('# Current shared skill\n'); + }); + + it('replaces a stale profile-local plugin cache while preserving plugin siblings', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + const currentSkillPath = path.join(cacheDir, cachedSkillRelativePath); + const siblingPath = path.join(pluginsDir, 'marketplaces.json'); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + fs.writeFileSync(siblingPath, '{"preserve":true}\n'); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true); + expect(fs.existsSync(staleSkillPath)).toBe(false); + expect(fs.readFileSync(currentSkillPath, 'utf8')).toBe('# Current shared skill\n'); + expect(fs.readFileSync(siblingPath, 'utf8')).toBe('{"preserve":true}\n'); + }); + + it('refuses a symlinked plugins parent without changing its external target', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const sharedPluginsDir = path.join(sharedCodexHome, 'plugins'); + const sharedSkillPath = path.join(sharedCodexHome, 'plugins', 'cache', cachedSkillRelativePath); + const profilePluginsDir = path.join(profileDir, 'plugins'); + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + fs.symlinkSync(sharedPluginsDir, profilePluginsDir, 'dir'); + + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile plugins path is not a local directory' + ); + + expect(fs.lstatSync(profilePluginsDir).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(path.join(sharedPluginsDir, 'cache')).isDirectory()).toBe(true); + expect(fs.readFileSync(sharedSkillPath, 'utf8')).toBe('# Current shared skill\n'); + }); + + it('keeps the plugin cache projection stable across repeated repairs', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const firstTarget = fs.readlinkSync(cacheDir); + const firstInode = fs.lstatSync(cacheDir).ino; + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + expect(fs.readlinkSync(cacheDir)).toBe(firstTarget); + expect(fs.lstatSync(cacheDir).ino).toBe(firstInode); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + }); + + it('keeps a concurrently-created correct cache link instead of restoring stale state', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + let injectedConcurrentRepair = false; + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if ( + !injectedConcurrentRepair && + path.resolve(String(pathToCreate)) === path.resolve(cacheDir) + ) { + injectedConcurrentRepair = true; + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } + return realSymlinkSync(...args); + } + ); + + try { + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } finally { + symlinkSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true); + expect(fs.existsSync(staleSkillPath)).toBe(false); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + expect( + fs.readdirSync(path.dirname(cacheDir)).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + + it('recovers when another repair moves the stale cache before this repair can rename it', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realRenameSync = fs.renameSync.bind(fs); + let injectedRenameRace = false; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation( + (...args: Parameters) => { + const [oldPath] = args; + if (!injectedRenameRace && path.resolve(String(oldPath)) === path.resolve(cacheDir)) { + injectedRenameRace = true; + fs.rmSync(cacheDir, { recursive: true, force: true }); + throw Object.assign(new Error('simulated concurrent move'), { code: 'ENOENT' }); + } + return realRenameSync(...args); + } + ); + + try { + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } finally { + renameSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true); + expect(fs.existsSync(staleSkillPath)).toBe(false); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + }); + + it('does not delete a concurrent cache path when symlink creation loses an EEXIST race', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + const concurrentMarkerPath = path.join(cacheDir, 'concurrent-marker.txt'); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(concurrentMarkerPath, 'preserve concurrent cache\n'); + throw Object.assign(new Error('simulated concurrent EEXIST'), { code: 'EEXIST' }); + } + return realSymlinkSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'simulated concurrent EEXIST' + ); + } finally { + symlinkSpy.mockRestore(); + } + + expect(fs.readFileSync(concurrentMarkerPath, 'utf8')).toBe('preserve concurrent cache\n'); + const backupNames = fs + .readdirSync(pluginsDir) + .filter((name) => name.startsWith('.cache.ccs-backup-')); + expect(backupNames).toHaveLength(1); + expect( + fs.readFileSync(path.join(pluginsDir, backupNames[0], staleCachedSkillRelativePath), 'utf8') + ).toBe('# Stale profile skill\n'); + }); + + it('merges rollback data when a concurrent cache appears during restoration', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + const concurrentMarkerPath = path.join(cacheDir, 'concurrent-marker.txt'); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated filesystem corruption'), { code: 'EIO' }); + } + return realSymlinkSync(...args); + } + ); + const realMkdirSync = fs.mkdirSync.bind(fs); + let injectedRestoreRace = false; + const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation( + (...args: Parameters) => { + const [pathToCreate] = args; + if (!injectedRestoreRace && path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + injectedRestoreRace = true; + realMkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(concurrentMarkerPath, 'preserve concurrent cache\n'); + throw Object.assign(new Error('simulated concurrent create'), { code: 'EEXIST' }); + } + return realMkdirSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'simulated filesystem corruption' + ); + } finally { + mkdirSpy.mockRestore(); + symlinkSpy.mockRestore(); + } + + expect(fs.readFileSync(concurrentMarkerPath, 'utf8')).toBe('preserve concurrent cache\n'); + expect(fs.readFileSync(staleSkillPath, 'utf8')).toBe('# Stale profile skill\n'); + expect( + fs.readdirSync(path.dirname(cacheDir)).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + + it('preserves a concurrent non-directory cache path during fallback restoration', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath); + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(staleSkillPath, '# Stale profile skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated Windows symlink denial'), { code: 'EPERM' }); + } + return realSymlinkSync(...args); + } + ); + const realMkdirSync = fs.mkdirSync.bind(fs); + let injectedRestoreRace = false; + const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation( + (...args: Parameters) => { + const [pathToCreate] = args; + if (!injectedRestoreRace && path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + injectedRestoreRace = true; + fs.writeFileSync(cacheDir, 'preserve concurrent file\n'); + throw Object.assign(new Error('simulated concurrent create'), { code: 'EEXIST' }); + } + return realMkdirSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile cache path is not a local directory' + ); + } finally { + mkdirSpy.mockRestore(); + symlinkSpy.mockRestore(); + } + + expect(fs.readFileSync(cacheDir, 'utf8')).toBe('preserve concurrent file\n'); + const backupNames = fs + .readdirSync(pluginsDir) + .filter((name) => name.startsWith('.cache.ccs-backup-')); + expect(backupNames).toHaveLength(1); + expect( + fs.readFileSync(path.join(pluginsDir, backupNames[0], staleCachedSkillRelativePath), 'utf8') + ).toBe('# Stale profile skill\n'); }); it('repairs a missing resource link without changing existing shared files', async () => { @@ -107,4 +419,88 @@ describe('ensureCodexProfileResources', () => { expect(fs.lstatSync(agentsPath).isDirectory()).toBe(true); expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true); }); + + it('preserves a local plugin cache and copies missing shared entries on EPERM', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + const cacheDir = path.join(profileDir, 'plugins', 'cache'); + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(cacheDir, path.dirname(staleCachedSkillRelativePath)), { + recursive: true, + mode: 0o700, + }); + fs.writeFileSync(path.join(cacheDir, 'local-only.txt'), 'keep me\n'); + fs.writeFileSync(path.join(cacheDir, staleCachedSkillRelativePath), '# Existing stale skill\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated Windows symlink denial'), { code: 'EPERM' }); + } + return realSymlinkSync(...args); + } + ); + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + + try { + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } finally { + process.stderr.write = origWrite; + symlinkSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isDirectory()).toBe(true); + expect(fs.readFileSync(path.join(cacheDir, 'local-only.txt'), 'utf8')).toBe('keep me\n'); + expect(fs.readFileSync(path.join(cacheDir, staleCachedSkillRelativePath), 'utf8')).toBe( + '# Existing stale skill\n' + ); + expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe( + '# Current shared skill\n' + ); + }); + + it('restores a stale plugin cache and rethrows an unexpected symlink error', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + const pluginsDir = path.join(profileDir, 'plugins'); + const cacheDir = path.join(pluginsDir, 'cache'); + const siblingPath = path.join(pluginsDir, 'marketplaces.json'); + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(cacheDir, 'local-only.txt'), 'restore me\n'); + fs.writeFileSync(siblingPath, '{"preserve":true}\n'); + + const realSymlinkSync = fs.symlinkSync.bind(fs); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation( + (...args: Parameters) => { + const [, pathToCreate] = args; + if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) { + throw Object.assign(new Error('simulated filesystem corruption'), { code: 'EIO' }); + } + return realSymlinkSync(...args); + } + ); + + try { + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'simulated filesystem corruption' + ); + } finally { + symlinkSpy.mockRestore(); + } + + expect(fs.lstatSync(cacheDir).isDirectory()).toBe(true); + expect(fs.readFileSync(path.join(cacheDir, 'local-only.txt'), 'utf8')).toBe('restore me\n'); + expect(fs.existsSync(path.join(cacheDir, cachedSkillRelativePath))).toBe(false); + expect(fs.readFileSync(siblingPath, 'utf8')).toBe('{"preserve":true}\n'); + }); }); From 960d6e23a0c85aef6fceef2afbe789e285c5d940 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 10:22:09 -0400 Subject: [PATCH 24/35] test(codex-auth): cover profile cache repair entry points --- tests/unit/bin/codex-runtime-router.test.ts | 36 +++++++++++++++++++ .../commands/create-command.test.ts | 33 +++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/tests/unit/bin/codex-runtime-router.test.ts b/tests/unit/bin/codex-runtime-router.test.ts index e9380831..7a639429 100644 --- a/tests/unit/bin/codex-runtime-router.test.ts +++ b/tests/unit/bin/codex-runtime-router.test.ts @@ -176,9 +176,45 @@ describe('codex-runtime router — non-auth profile resolution', () => { expect(process.env.CCS_CODEX_PROFILE).toBe('ck'); expect(process.env.CODEX_HOME).toBe(profileDir); expect(fs.existsSync(path.join(profileDir, 'agents', 'brainstormer.toml'))).toBe(true); + expect(fs.lstatSync(path.join(profileDir, 'plugins', 'cache')).isSymbolicLink()).toBe(true); expect(argv).toEqual(['node', 'codex-runtime', 'default', 'fix failing tests']); }); + it('repairs a positional ccsx profile before delegating to CCS', async () => { + const profileDir = makeProfileDir('ck'); + writeRegistry({ + version: '1.0', + default: null, + profiles: { ck: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } }, + }); + const events: string[] = []; + + flushRouterCache(); + require.cache[resourcePath] = { + exports: { + ensureCodexProfileResources: (dir: string) => { + expect(dir).toBe(profileDir); + events.push('repair'); + }, + }, + } as NodeJS.Module; + require.cache[ccsPath] = Object.defineProperty({} as NodeJS.Module, 'exports', { + configurable: true, + get: () => { + events.push('ccs'); + return {}; + }, + }); + + const argv = ['node', 'codex-runtime', 'ck']; + const { main } = require(routerPath) as { main: (args: string[]) => Promise }; + const code = await main(argv); + + expect(code).toBe(-1); + expect(events).toEqual(['repair', 'ccs']); + expect(argv).toEqual(['node', 'codex-runtime', 'default']); + }); + it('self-heals missing Codex profile resources during launch', async () => { const profileDir = makeProfileDir('ck'); writeRegistry({ diff --git a/tests/unit/codex-auth/commands/create-command.test.ts b/tests/unit/codex-auth/commands/create-command.test.ts index 39d90e83..042878c7 100644 --- a/tests/unit/codex-auth/commands/create-command.test.ts +++ b/tests/unit/codex-auth/commands/create-command.test.ts @@ -21,6 +21,24 @@ beforeEach(() => { fs.writeFileSync(path.join(homeDir, '.codex', 'agents', 'brainstormer.toml'), 'name = "b"\n'); fs.mkdirSync(path.join(homeDir, '.codex', 'skills'), { recursive: true }); fs.writeFileSync(path.join(homeDir, '.codex', 'skills', 'review.md'), '# Review\n'); + fs.mkdirSync( + path.join(homeDir, '.codex', 'plugins', 'cache', 'claudekit', '1.0.0', 'skills', 'test'), + { recursive: true } + ); + fs.writeFileSync( + path.join( + homeDir, + '.codex', + 'plugins', + 'cache', + 'claudekit', + '1.0.0', + 'skills', + 'test', + 'SKILL.md' + ), + '# Test skill\n' + ); fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); process.env.CCS_HOME = ccsHome; spyOn(os, 'homedir').mockReturnValue(homeDir); @@ -96,6 +114,21 @@ describe('handleCreateCodex — happy path', () => { expect(fs.existsSync(instancesDir)).toBe(true); expect(fs.lstatSync(path.join(instancesDir, 'agents')).isSymbolicLink()).toBe(true); expect(fs.lstatSync(path.join(instancesDir, 'skills')).isSymbolicLink()).toBe(true); + expect( + fs.readFileSync( + path.join( + instancesDir, + 'plugins', + 'cache', + 'claudekit', + '1.0.0', + 'skills', + 'test', + 'SKILL.md' + ), + 'utf8' + ) + ).toBe('# Test skill\n'); }); }); From 3103af5355448b9137828b062b50a64f8639902f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 10:22:26 -0400 Subject: [PATCH 25/35] docs(codex-auth): document shared plugin cache --- docs/codex-auth.md | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/codex-auth.md b/docs/codex-auth.md index 4dd2c5eb..d25420ba 100644 --- a/docs/codex-auth.md +++ b/docs/codex-auth.md @@ -10,8 +10,9 @@ refresh in one session overwrites the other's credentials. `ccsx auth` solves this by giving each account its own profile directory under `~/.ccs/codex-instances//`. Each profile holds its own `auth.json` and -`history.jsonl`. Shared `config.toml`, `agents/`, and `skills/` resources are linked -via symlink so model/provider settings and relative agent role config files stay in sync. +`history.jsonl`, plus its own session data. Shared `config.toml`, `agents/`, `skills/`, +and plugin cache resources come from `~/.codex/` so configuration and installed plugin +skills stay in sync across profiles. ## Quick start (4 commands) @@ -149,17 +150,26 @@ No OAuth tokens are ever returned by the API endpoint or shown in the UI. ├── sessions/ # Per-profile chat session dirs (optional) ├── config.toml -> ~/.codex/config.toml (symlink — shared) ├── agents/ -> ~/.codex/agents/ (symlink — shared) - └── skills/ -> ~/.codex/skills/ (symlink — shared) + ├── skills/ -> ~/.codex/skills/ (symlink — shared) + └── plugins/ # Profile-local parent; may hold local metadata + └── cache/ -> ~/.codex/plugins/cache/ (symlink — shared) ~/.codex/ ├── config.toml # Single shared model/provider config ├── agents/ # Shared Codex agent role config files -└── skills/ # Shared Codex skills +├── skills/ # Shared Codex skills +└── plugins/ + └── cache/ # Shared installed plugin payloads ``` -`ccsx auth create ` and `ccsx ` both repair these links idempotently. -This keeps relative Codex config entries such as `agents/foo.toml` valid inside -each isolated `CODEX_HOME`. +Only `plugins/cache/` is shared. The profile's parent `plugins/` directory remains a +real local directory so Codex can keep profile-specific plugin metadata beside the +shared cache. + +`ccsx auth create ` and direct `ccsx ` launches repair these links +idempotently before Codex starts. This keeps relative entries such as +`agents/foo.toml` valid and prevents stale first-launch skill warnings after a plugin +install or update changes the cache. ## Caveats @@ -167,9 +177,10 @@ each isolated `CODEX_HOME`. On Windows, creating symlinks requires Developer Mode or elevated privileges. If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`, -and `skills/`. In this case, changes to `~/.codex/` resources are **not** -automatically reflected in the profile; re-run `ccsx auth create --force` -to refresh the copy. +`skills/`, and the current `plugins/cache/` snapshot. Copies do not update live with +`~/.codex/`; after a plugin update, another profile launch or +`ccsx auth create --force` repair copies newly missing cache entries. Existing +profile-local cache files are preserved. ### Native Codex project-local config warnings From 234dce31500b2b147a0cfe3035722242fea74c40 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Jul 2026 11:36:15 -0400 Subject: [PATCH 26/35] fix(codex-auth): reject aliased plugin cache paths --- src/codex-auth/codex-profile-plugin-cache.ts | 8 ++- .../codex-profile-resources.test.ts | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/codex-auth/codex-profile-plugin-cache.ts b/src/codex-auth/codex-profile-plugin-cache.ts index 432f41fa..f2a32e61 100644 --- a/src/codex-auth/codex-profile-plugin-cache.ts +++ b/src/codex-auth/codex-profile-plugin-cache.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { ConfigError } from '../errors/error-types'; -import { symlinkPointsTo } from '../management/shared-manager/fs-helpers'; +import { resolveCanonicalPath, symlinkPointsTo } from '../management/shared-manager/fs-helpers'; import { createLogger } from '../services/logging'; const logger = createLogger('codex-auth:resources'); @@ -15,6 +15,12 @@ export function ensureSharedPluginCache(profileDir: string, sharedCodexHome: str ensureProfileLocalPluginsDirectory(pluginsPath); fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); + if (resolveCanonicalPath(pluginsPath) === resolveCanonicalPath(path.dirname(targetPath))) { + throw new ConfigError( + 'Refusing plugin cache repair: profile plugins directory resolves to the shared plugins directory.' + ); + } + const existingStat = lstatIfExists(linkPath); if (isExpectedCacheLink(linkPath, targetPath, existingStat)) { return; diff --git a/tests/unit/codex-auth/codex-profile-resources.test.ts b/tests/unit/codex-auth/codex-profile-resources.test.ts index 215bf248..5d80180e 100644 --- a/tests/unit/codex-auth/codex-profile-resources.test.ts +++ b/tests/unit/codex-auth/codex-profile-resources.test.ts @@ -110,6 +110,59 @@ describe('ensureCodexProfileResources', () => { expect(fs.readFileSync(sharedSkillPath, 'utf8')).toBe('# Current shared skill\n'); }); + it('refuses a profile root that resolves to the shared Codex home without changing its cache', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const sharedPluginsDir = path.join(sharedCodexHome, 'plugins'); + const sharedCacheDir = path.join(sharedPluginsDir, 'cache'); + const sharedMarkerPath = path.join(sharedCacheDir, 'shared-marker.txt'); + fs.writeFileSync(sharedMarkerPath, 'preserve shared cache\n'); + const cacheInodeBeforeRepair = fs.lstatSync(sharedCacheDir).ino; + fs.symlinkSync(sharedCodexHome, profileDir, 'dir'); + + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile plugins directory resolves to the shared plugins directory' + ); + + expect(fs.lstatSync(profileDir).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(sharedCacheDir).isDirectory()).toBe(true); + expect(fs.lstatSync(sharedCacheDir).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(sharedCacheDir).ino).toBe(cacheInodeBeforeRepair); + expect(fs.readFileSync(sharedMarkerPath, 'utf8')).toBe('preserve shared cache\n'); + expect( + fs.readdirSync(sharedPluginsDir).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + + it('refuses a shared plugins directory that resolves to the profile plugins directory', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const profilePluginsDir = path.join(profileDir, 'plugins'); + const profileCacheDir = path.join(profilePluginsDir, 'cache'); + const profileMarkerPath = path.join(profileCacheDir, 'profile-marker.txt'); + const sharedPluginsDir = path.join(sharedCodexHome, 'plugins'); + fs.mkdirSync(profileCacheDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(profileMarkerPath, 'preserve profile cache\n'); + const cacheInodeBeforeRepair = fs.lstatSync(profileCacheDir).ino; + fs.rmSync(sharedPluginsDir, { recursive: true, force: true }); + fs.symlinkSync(profilePluginsDir, sharedPluginsDir, 'dir'); + + expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow( + 'profile plugins directory resolves to the shared plugins directory' + ); + + expect(fs.lstatSync(sharedPluginsDir).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(profileCacheDir).isDirectory()).toBe(true); + expect(fs.lstatSync(profileCacheDir).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(profileCacheDir).ino).toBe(cacheInodeBeforeRepair); + expect(fs.readFileSync(profileMarkerPath, 'utf8')).toBe('preserve profile cache\n'); + expect( + fs.readdirSync(profilePluginsDir).some((name) => name.startsWith('.cache.ccs-backup-')) + ).toBe(false); + }); + it('keeps the plugin cache projection stable across repeated repairs', async () => { const { ensureCodexProfileResources } = await import( '../../../src/codex-auth/codex-profile-resources' From 1b67c7840b30543034bd16107fbaba5846ed1200 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Jul 2026 15:56:56 +0000 Subject: [PATCH 27/35] chore(release): 8.7.0-dev.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4ae74f41..57a78795 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.9", + "version": "8.7.0-dev.10", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 54a2b4759b7bf831ba6801cb71f97b6aef24481c Mon Sep 17 00:00:00 2001 From: minhbi245 <8097269+minhbi245@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:02:12 +0700 Subject: [PATCH 28/35] feat(cliproxy): add xAI Grok provider --- README.md | 3 +- config/base-xai.settings.json | 10 +++ docs/project-overview-pdr.md | 6 +- docs/system-architecture/provider-flows.md | 52 +++++++++----- src/auth/profile-detector.ts | 12 ++-- .../__tests__/model-catalog-compat.test.ts | 19 +++++ src/cliproxy/__tests__/model-catalog.test.js | 26 +++++++ .../__tests__/provider-capabilities.test.ts | 25 +++++++ .../account-manager-discover.test.js | 30 ++++++++ src/cliproxy/accounts/types.ts | 2 +- .../auth-types-management-path.test.ts | 1 + .../__tests__/oauth-handler-xai-args.test.ts | 20 ++++++ .../oauth-process-error-parser.test.ts | 17 +++++ src/cliproxy/auth/auth-types.ts | 8 +++ src/cliproxy/auth/oauth-handler.ts | 4 +- src/cliproxy/auth/oauth-process.ts | 29 +++++--- .../auth/provider-refreshers/index.ts | 4 +- .../__tests__/base-config-loader.test.ts | 1 + src/cliproxy/config/generator.ts | 2 +- src/cliproxy/model-catalog.ts | 69 +++++++++++++++++++ src/cliproxy/provider-capabilities.ts | 46 +++++++++++++ src/cliproxy/services/catalog-cache.ts | 1 + src/cliproxy/types/provider-types.ts | 1 + src/commands/command-catalog.ts | 16 ++++- src/commands/completion-backend.ts | 9 ++- src/config/loader/yaml-serializer.ts | 2 +- src/dispatcher/pre-dispatch.ts | 13 ++-- tests/unit/auth/profile-detector.test.ts | 8 +++ .../unit/commands/completion-backend.test.ts | 7 ++ .../unit/commands/help-command-parity.test.ts | 2 + ui/public/assets/providers/xai.svg | 4 ++ .../components/account/add-account-dialog.tsx | 2 +- ui/src/components/setup/wizard/constants.ts | 1 + ui/src/lib/model-catalogs.ts | 62 +++++++++++++++++ ui/src/lib/provider-config.ts | 4 ++ .../unit/ui/lib/model-catalogs-xai.test.ts | 30 ++++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 16 +++++ 37 files changed, 510 insertions(+), 54 deletions(-) create mode 100644 config/base-xai.settings.json create mode 100644 src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts create mode 100644 ui/public/assets/providers/xai.svg create mode 100644 ui/tests/unit/ui/lib/model-catalogs-xai.test.ts diff --git a/README.md b/README.md index 9eea7e95..bb57a1b4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Then launch whatever runtime fits the task: ```bash ccs ccs codex +ccs xai # Alias: ccs grok ccs --target droid glm ccs glm ccs ollama @@ -61,7 +62,7 @@ CCS gives you one stable command surface while letting you switch between: - multiple runtimes such as Claude Code, Factory Droid, and Codex CLI - multiple Claude subscriptions and isolated account contexts -- OAuth providers like Codex, Kiro, Claude, Kimi, and more, with legacy +- OAuth providers like Codex, xAI/Grok, Kiro, Claude, Kimi, and more, with legacy Copilot compatibility for existing setups - API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, Novita, Fireworks AI, and Alibaba Coding Plan diff --git a/config/base-xai.settings.json b/config/base-xai.settings.json new file mode 100644 index 00000000..0a908b8e --- /dev/null +++ b/config/base-xai.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/xai", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "grok-build-0.1", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "grok-4.5", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "grok-build-0.1", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "grok-composer-2.5-fast" + } +} diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index d4e6449c..de9d4f86 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -31,7 +31,7 @@ Developers using Claude Code face these challenges: CCS provides: 1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` -2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups +2. **OAuth Providers**: Zero-config Gemini, Codex, xAI/Grok, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups 3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 5. **Visual Dashboard**: React SPA for configuration management @@ -68,8 +68,8 @@ CCS provides: - Share commands, skills, agents across accounts ### FR-003: OAuth Provider Integration -- Support Gemini, Codex, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows -- Browser-based authentication (Authorization Code flow for most, Device Code for ghcp compatibility) +- Support Gemini, Codex, xAI/Grok, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows +- Browser-based authentication with provider-specific Authorization Code, Device Code, or polling flows - Token caching and refresh ### FR-004: API Profile Management diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 98c2e49d..39b516c3 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -1,6 +1,6 @@ # Provider Integration Flows -Last Updated: 2026-05-07 +Last Updated: 2026-07-16 Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication. @@ -35,13 +35,13 @@ Generated local CLIProxy configs also keep the management dashboard aligned with +---> OAuth Authentication | | | +---> Authorization Code Flow (port-based) - | | - Gemini, Codex, Antigravity, Kiro (port 9876) + | | - Gemini, Codex, Antigravity, iFlow, Claude, GitLab | | - Opens browser for user auth | | - Callback to localhost:PORT | | | +---> Device Code Flow (no port needed) - | - GitHub Copilot (ghcp, deprecated compatibility) - | - User enters code at github.com/login/device + | - xAI/Grok, Kimi, Kiro, Copilot, CodeBuddy, Kilo, and Qoder + | - User enters a code on the provider verification page | - Polls for token completion | | | +---> Browser URL Polling (no callback port) @@ -70,23 +70,37 @@ Generated local CLIProxy configs also keep the management dashboard aligned with +---> Provider APIs | +---> Google (Gemini) - +---> GitHub (Codex) + +---> OpenAI (Codex) + +---> xAI (Grok) +---> Antigravity (AGY) +---> AWS Kiro (Claude-powered) +---> GitHub Copilot (ghcp, deprecated compatibility) +---> OpenAI-compatible endpoints ``` -### Supported Hardcoded Providers +### Supported Built-In Providers -| Provider | ID | Auth Method | Port | Binary | +| Provider | ID | Auth Method | Callback Port | Backend | |----------|----|----|------|--------| -| Gemini | `gemini` | Authorization Code | 9876 | CLIProxyAPI | -| Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI | -| Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | -| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | -| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | -| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork | +| Gemini | `gemini` | Authorization Code | 8085 | Original | +| Codex | `codex` | Authorization Code | 1455 | Original | +| xAI (Grok) | `xai` (`grok` CLI alias) | Device Code | none | Original | +| Antigravity | `agy` | Authorization Code | 51121 | Original | +| Qwen | `qwen` | Account linking unavailable in bundled runtime | none | Original | +| iFlow | `iflow` | Authorization Code | 11451 | Original | +| Claude | `claude` | Authorization Code | 54545 | Original | +| Kimi | `kimi` | Device Code | none | Original | +| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | none by default | Plus | +| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | Plus | +| Cursor | `cursor` | Browser URL polling | none | Plus | +| GitLab Duo | `gitlab` | Authorization Code or PAT | 17171 | Plus | +| CodeBuddy | `codebuddy` | Device-style polling | none | Plus | +| Kilo AI | `kilo` | Device Code | none | Plus | +| Qoder | `qoder` | Device Code | none | Plus | + +xAI uses CLIProxyAPI's `--xai-login` device flow. CCS stores and discovers the resulting +`xai-*.json` credentials under the canonical `xai` provider. `ccs grok` is a command alias only, +so it shares the same accounts, settings, live model catalog, and routing as `ccs xai`. ### Codex Duplicate-Email Account Identity @@ -100,16 +114,16 @@ Codex can legitimately produce multiple auth files for the same email when the u This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model. -### Hardcoded Provider Detection +### Built-In Provider Detection -CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`. +CCS derives canonical providers from `provider-capabilities.ts`. `profile-detector.ts` resolves only +explicit CLI aliases before routing through the CLIProxy execution flow. ```typescript -// Profile name matching -const hardcodedProviders = ['gemini', 'codex', 'agy', 'kiro', 'ghcp']; +const provider = resolveCLIProxyProviderShortcut(profileName); -if (hardcodedProviders.includes(profileName)) { - return execClaudeWithCLIProxy(claudeCli, profileName, args); +if (provider) { + return execClaudeWithCLIProxy(claudeCli, provider, args); } ``` diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 48948a8e..dbac96b3 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -24,7 +24,10 @@ import { import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; -import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { + CLIPROXY_PROVIDER_IDS, + resolveCLIProxyProviderShortcut, +} from '../cliproxy/provider-capabilities'; import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; @@ -332,11 +335,12 @@ class ProfileDetector { } // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) - if (isCLIProxyProvider(profileName)) { + const builtinProvider = resolveCLIProxyProviderShortcut(profileName); + if (builtinProvider) { return { type: 'cliproxy', - name: profileName, - provider: profileName, + name: builtinProvider, + provider: builtinProvider, }; } diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index 9fe98a30..d7e4dfda 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -37,11 +37,30 @@ describe('model-catalog compatibility lookups', () => { }); it('maps all dashboard providers to upstream catalog channels', () => { + expect(SYNCABLE_PROVIDERS).toContain('xai'); expect(SYNCABLE_PROVIDERS).toContain('qwen'); expect(SYNCABLE_PROVIDERS).toContain('iflow'); expect(SYNCABLE_PROVIDERS).toContain('kiro'); expect(SYNCABLE_PROVIDERS).toContain('ghcp'); expect(PROVIDER_TO_CHANNEL.ghcp).toBe('github-copilot'); + expect(PROVIDER_TO_CHANNEL.xai).toBe('xai'); + }); + + it('merges live xAI model metadata while preserving the static coding default', () => { + const catalog = mergeCatalog('xai', [ + { id: 'grok-build-0.1', display_name: 'Grok Build 0.1' }, + { + id: 'grok-4.5', + display_name: 'Grok 4.5', + thinking: { levels: ['low', 'medium', 'high'] }, + }, + ]); + + expect(catalog?.defaultModel).toBe('grok-build-0.1'); + expect(catalog?.models[1]?.thinking).toMatchObject({ + type: 'levels', + levels: ['low', 'medium', 'high'], + }); }); it('does not re-add stale static-only models when live catalog data is present', () => { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 34d0a133..29ac5d5d 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -63,6 +63,32 @@ describe('Model Catalog', () => { }); }); + describe('xAI models', () => { + it('contains the xAI provider catalog with the coding default', () => { + const { MODEL_CATALOG } = modelCatalog; + assert(MODEL_CATALOG.xai, 'Should have xai provider'); + assert.strictEqual(MODEL_CATALOG.xai.provider, 'xai'); + assert.strictEqual(MODEL_CATALOG.xai.displayName, 'xAI (Grok)'); + assert.strictEqual(MODEL_CATALOG.xai.defaultModel, 'grok-build-0.1'); + }); + + it('matches the CLIProxyAPI text-model fallback set', () => { + const { MODEL_CATALOG } = modelCatalog; + const ids = MODEL_CATALOG.xai.models.map((model) => model.id); + assert.deepStrictEqual(ids, [ + 'grok-build-0.1', + 'grok-4.5', + 'grok-4.3', + 'grok-4.20-0309-reasoning', + 'grok-4.20-0309-non-reasoning', + 'grok-4.20-multi-agent-0309', + 'grok-3-mini', + 'grok-3-mini-fast', + 'grok-composer-2.5-fast', + ]); + }); + }); + describe('AGY models', () => { it('has correct default model', () => { const { MODEL_CATALOG } = modelCatalog; diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index b9665bd2..1b480026 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -11,6 +11,7 @@ import { getProvidersByOAuthFlow, isCLIProxyProvider, mapExternalProviderName, + resolveCLIProxyProviderShortcut, QUOTA_SUPPORTED_PROVIDER_IDS, isQuotaSupportedProvider, QUOTA_PROVIDER_OPTION_VALUES, @@ -25,6 +26,7 @@ import { getKiroCallbackPort, getKiroCLIAuthArgs, getKiroCLIAuthFlag, + getOAuthConfig, normalizeKiroIDCFlow, normalizeKiroAuthMethod, OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS, @@ -36,6 +38,7 @@ describe('provider-capabilities', () => { expect(CLIPROXY_PROVIDER_IDS).toEqual([ 'gemini', 'codex', + 'xai', 'agy', 'qwen', 'iflow', @@ -61,6 +64,7 @@ describe('provider-capabilities', () => { it('returns providers by OAuth flow capability', () => { expect(getProvidersByOAuthFlow('device_code')).toEqual([ + 'xai', 'qwen', 'kiro', 'ghcp', @@ -82,6 +86,7 @@ describe('provider-capabilities', () => { it('separates browser URL auth providers from verification-code device flows', () => { expect(getDeviceCodeVerificationProviders()).toEqual([ + 'xai', 'qwen', 'kiro', 'ghcp', @@ -101,11 +106,20 @@ describe('provider-capabilities', () => { expect(mapExternalProviderName('anthropic')).toBe('claude'); expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab'); expect(mapExternalProviderName('tencent')).toBe('codebuddy'); + expect(mapExternalProviderName('grok')).toBe('xai'); + expect(mapExternalProviderName('x.ai')).toBe('xai'); expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); expect(mapExternalProviderName('')).toBeNull(); expect(mapExternalProviderName('unknown-provider')).toBeNull(); }); + it('keeps CLI aliases explicit and canonical', () => { + expect(resolveCLIProxyProviderShortcut('xai')).toBe('xai'); + expect(resolveCLIProxyProviderShortcut('grok')).toBe('xai'); + expect(resolveCLIProxyProviderShortcut(' GROK ')).toBe('xai'); + expect(resolveCLIProxyProviderShortcut('anthropic')).toBeNull(); + }); + it('exposes quota-supported providers and guards correctly', () => { expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'claude', 'gemini', 'ghcp']); expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([ @@ -130,6 +144,7 @@ describe('provider-capabilities', () => { it('exposes callback port and display name capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); + expect(getOAuthCallbackPort('xai')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); expect(getOAuthCallbackPort('cursor')).toBeNull(); expect(getOAuthCallbackPort('gitlab')).toBe(17171); @@ -138,6 +153,7 @@ describe('provider-capabilities', () => { expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported'); expect(getProviderDisplayName('agy')).toBe('Antigravity'); expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); + expect(getProviderDisplayName('xai')).toBe('xAI (Grok)'); }); it('exposes auth start support separately from OAuth flow type', () => { @@ -147,6 +163,15 @@ describe('provider-capabilities', () => { ); expect(getUnsupportedAuthStartReason('kiro')).toBeNull(); expect(getUnsupportedAuthStartReason('qoder')).toBeNull(); + expect(getUnsupportedAuthStartReason('xai')).toBeNull(); + }); + + it('uses the CLIProxyAPI xAI OAuth command contract', () => { + expect(getOAuthConfig('xai')).toMatchObject({ + provider: 'xai', + authFlag: '--xai-login', + }); + expect(AUTH_CALLBACK_PORTS.xai).toBeUndefined(); }); it('throws when provider aliases collide across providers', () => { diff --git a/src/cliproxy/accounts/__tests__/account-manager-discover.test.js b/src/cliproxy/accounts/__tests__/account-manager-discover.test.js index 31cc52a1..9899ad7a 100644 --- a/src/cliproxy/accounts/__tests__/account-manager-discover.test.js +++ b/src/cliproxy/accounts/__tests__/account-manager-discover.test.js @@ -128,6 +128,36 @@ describe('Account Manager - discoverExistingAccounts', () => { assert(accounts.providers.ghcp, 'copilot type should map to ghcp provider'); }); + it('discovers xAI accounts with type="xai"', () => { + createAuthFile('xai-user@example.com.json', { + type: 'xai', + email: 'user@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert(accounts.providers.xai, 'xai type should map to xai provider'); + assert(accounts.providers.xai.accounts['user@example.com']); + }); + + it('keeps multiple xAI accounts distinct when token metadata has no email', () => { + createAuthFile('xai-subject-one.json', { + type: 'xai', + }); + createAuthFile('xai-subject-two.json', { + type: 'xai', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert.deepStrictEqual(Object.keys(accounts.providers.xai.accounts).sort(), [ + 'subject-one', + 'subject-two', + ]); + }); + it('handles case-insensitive type values', () => { createAuthFile('kiro-test.json', { type: 'KIRO', diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts index f64b2cd6..839510ee 100644 --- a/src/cliproxy/accounts/types.ts +++ b/src/cliproxy/accounts/types.ts @@ -11,7 +11,7 @@ export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; * Providers that typically have empty email in OAuth token files. * For these providers, nickname is used as accountId instead of email. */ -export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp']; +export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp', 'xai']; /** Account information */ export interface AccountInfo { diff --git a/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts b/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts index 221f0f57..d81b97a3 100644 --- a/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts +++ b/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts @@ -11,6 +11,7 @@ describe('auth-types paste-callback start path', () => { '/v0/management/gemini-cli-auth-url?is_webui=true' ); expect(getPasteCallbackStartPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true'); + expect(getPasteCallbackStartPath('xai')).toBe('/v0/management/xai-auth-url?is_webui=true'); expect(getPasteCallbackStartPath('agy')).toBe( '/v0/management/antigravity-auth-url?is_webui=true' ); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts new file mode 100644 index 00000000..6a91c889 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'bun:test'; +import { OAUTH_CALLBACK_PORTS } from '../../../management/oauth-port-diagnostics'; +import { buildOAuthArgs } from '../oauth-handler'; + +describe('xAI OAuth process arguments', () => { + it('starts CLIProxy device auth without a callback port argument', () => { + expect(OAUTH_CALLBACK_PORTS.xai).toBeNull(); + expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([ + '--config', + '/tmp/cliproxy-config.yaml', + '--xai-login', + ]); + }); + + it('uses no-browser mode in headless environments', () => { + expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', true, false)).toContain( + '--no-browser' + ); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts index 87756f16..9ae13c83 100644 --- a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts @@ -4,11 +4,28 @@ import { extractLikelyAuthFailureFromLogs, extractLikelyAuthFailureFromStderr, extractLikelyOAuthAuthorizationUrl, + extractDeviceCodePrompt, getExpectedLocalCallback, getKiroBuilderIdSelectionInput, validateManualCallbackUrl, } from '../oauth-process'; +describe('oauth-process device code parsing', () => { + it('parses the xAI device authorization output contract', () => { + const output = ` +To authenticate, please visit: +https://accounts.x.ai/oauth2/device?user_code=ABCD-1234 + +Then enter this code: ABCD-1234 +`; + + expect(extractDeviceCodePrompt(output)).toEqual({ + userCode: 'ABCD-1234', + verificationUrl: 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234', + }); + }); +}); + describe('oauth-process stderr parsing', () => { it('does not match provider-specific patterns for other providers', () => { const stderr = diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index ee44afa1..3e321a8b 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -149,6 +149,7 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' * OAuth flow types per provider: * - Gemini: Authorization Code Flow with local callback server on port 8085 * - Codex: Authorization Code Flow with local callback server on port 1455 + * - xAI: Device Code Flow (polling-based, NO callback port needed) * - Agy: Authorization Code Flow with local callback server on port 51121 * - iFlow: Authorization Code Flow with local callback server on port 11451 * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) @@ -228,6 +229,13 @@ export const OAUTH_CONFIGS: Record = { scopes: ['openid', 'profile'], authFlag: '--codex-login', }, + xai: { + provider: 'xai', + displayName: 'xAI (Grok)', + authUrl: 'https://accounts.x.ai/oauth2/device', + scopes: ['openid', 'profile', 'email', 'offline_access', 'grok-cli:access', 'api:access'], + authFlag: '--xai-login', + }, agy: { provider: 'agy', displayName: 'Antigravity', diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2cde9b35..14264460 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -1,7 +1,7 @@ /** * OAuth Handler for CLIProxyAPI * - * Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity, Kiro, Copilot). + * Manages OAuth authentication flow for CLIProxy providers such as Gemini, Codex, xAI, and Kiro. * CLIProxyAPI handles OAuth internally - we just need to: * 1. Check if auth exists (token files in CCS auth directory) * 2. Trigger OAuth flow by spawning binary with auth flag @@ -605,7 +605,7 @@ async function prepareBinary( } } -function buildOAuthArgs( +export function buildOAuthArgs( provider: CLIProxyProvider, configPath: string, headless: boolean, diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 10c9a806..f94b77f7 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -127,6 +127,22 @@ function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'aut return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; } +export function extractDeviceCodePrompt(output: string): { + userCode: string; + verificationUrl: string | null; +} | null { + const codeMatch = output.match( + /(?:enter\s+)?(?:this\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i + ); + if (!codeMatch) return null; + + const urlMatch = output.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + return { + userCode: codeMatch[1].toUpperCase(), + verificationUrl: urlMatch?.[1] ?? null, + }; +} + export function isLoopbackHost(hostname: string): boolean { const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); return ( @@ -392,19 +408,14 @@ async function handleStdout( // Handle Device Code Flow: parse and display user code if (isDeviceCodeFlow && !state.deviceCodeDisplayed) { - // Parse device/user code from various formats: - // "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY" - const codeMatch = state.accumulatedOutput.match( - /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i - ); - const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i); + const devicePrompt = extractDeviceCodePrompt(state.accumulatedOutput); - if (codeMatch) { - state.userCode = codeMatch[1].toUpperCase(); + if (devicePrompt) { + state.userCode = devicePrompt.userCode; state.deviceCodeDisplayed = true; log(`Parsed device code: ${state.userCode}`); - const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device'; + const verificationUrl = devicePrompt.verificationUrl || 'https://github.com/login/device'; // Emit device code event for WebSocket broadcast to UI const deviceCodePrompt: DeviceCodePrompt = { diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index aa9925f5..3615f806 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -4,8 +4,8 @@ * Exports refresh functions for each OAuth provider. * * Refresh responsibility: - * - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, iflow, kimi - * (CLIProxyAPIPlus handles refresh automatically in background) + * - CLIProxy-delegated: gemini, codex, xai, agy, kiro, ghcp, iflow, kimi + * (the selected CLIProxy backend handles refresh automatically in background) * - Unsupported account linking: qwen * - Not implemented: claude */ diff --git a/src/cliproxy/config/__tests__/base-config-loader.test.ts b/src/cliproxy/config/__tests__/base-config-loader.test.ts index 4a5bd42a..f1a7dde3 100644 --- a/src/cliproxy/config/__tests__/base-config-loader.test.ts +++ b/src/cliproxy/config/__tests__/base-config-loader.test.ts @@ -9,6 +9,7 @@ describe('base-config-loader new providers', () => { ['codebuddy', '/api/provider/codebuddy', 'auto'], ['kilo', '/api/provider/kilo', 'kilo/auto'], ['qoder', '/api/provider/qoder', 'qoder/auto'], + ['xai', '/api/provider/xai', 'grok-build-0.1'], ] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => { const config = loadBaseConfig(provider); diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index a12906fb..4bddff8a 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -698,7 +698,7 @@ routing: // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} -# Supports: gemini, codex, agy, qwen, iflow (concurrent usage) +# Supports: gemini, codex, xai, agy, qwen, iflow, and other registered providers (concurrent usage) # Generated: ${new Date().toISOString()} # # This config is auto-managed by CCS. Manual edits may be overwritten. diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 94cf22b6..f698b590 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -301,6 +301,75 @@ export const MODEL_CATALOG: Partial> = }, ], }, + xai: { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-build-0.1', + models: [ + { + id: 'grok-build-0.1', + name: 'Grok Build 0.1', + description: 'Fast coding model for agentic software engineering workflows', + }, + { + id: 'grok-4.5', + name: 'Grok 4.5', + description: 'Frontier model for coding, engineering, and agentic workflows', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + zeroAllowed: false, + }, + }, + { + id: 'grok-4.3', + name: 'Grok 4.3', + description: 'General-purpose Grok model with a one-million-token context window', + extendedContext: true, + thinking: { + type: 'levels', + levels: ['none', 'low', 'medium', 'high'], + zeroAllowed: true, + }, + }, + { + id: 'grok-4.20-0309-reasoning', + name: 'Grok 4.20 0309 Reasoning', + description: 'Reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-0309-non-reasoning', + name: 'Grok 4.20 0309 Non Reasoning', + description: 'Non-reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-multi-agent-0309', + name: 'Grok 4.20 Multi Agent 0309', + description: 'Multi-agent model with a two-million-token context window', + extendedContext: true, + thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, + }, + { + id: 'grok-3-mini', + name: 'Grok 3 Mini', + description: 'Compact reasoning model', + thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, + }, + { + id: 'grok-3-mini-fast', + name: 'Grok 3 Mini Fast', + description: 'Faster compact reasoning model', + thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, + }, + { + id: 'grok-composer-2.5-fast', + name: 'Grok Composer 2.5 Fast', + description: 'Fast Composer model for lightweight coding tasks', + }, + ], + }, qoder: { provider: 'qoder', displayName: 'Qoder', diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index b72bf07f..b9436271 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -24,6 +24,8 @@ export interface ProviderCapabilities { authFilePrefixes: readonly string[]; /** Token JSON "type" values accepted for this provider. */ tokenTypeValues: readonly string[]; + /** Alternative names accepted as first-class CCS CLI shortcuts. */ + cliAliases?: readonly string[]; /** * Alternative provider names used by CLIProxyAPI or stats endpoints. * These aliases normalize external names to canonical CCS provider IDs. @@ -58,6 +60,20 @@ export const PROVIDER_CAPABILITIES: Record = buildProviderAliasMap(); +const PROVIDER_CLI_SHORTCUT_MAP: ReadonlyMap = (() => { + const shortcutMap = new Map(); + for (const provider of CLIPROXY_PROVIDER_IDS) { + shortcutMap.set(provider, provider); + for (const alias of PROVIDER_CAPABILITIES[provider].cliAliases ?? []) { + const normalized = alias.trim().toLowerCase(); + if (!normalized) continue; + const existingProvider = shortcutMap.get(normalized); + if (existingProvider && existingProvider !== provider) { + throw new ConfigError( + `Provider CLI alias collision for "${normalized}": ${existingProvider} and ${provider}` + ); + } + shortcutMap.set(normalized, provider); + } + } + return shortcutMap; +})(); + export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider { return PROVIDER_ID_SET.has(provider as CLIProxyProvider); } +/** Resolve canonical provider IDs and explicitly supported CLI shortcut aliases. */ +export function resolveCLIProxyProviderShortcut(providerName: string): CLIProxyProvider | null { + const normalized = providerName.trim().toLowerCase(); + if (!normalized) return null; + return PROVIDER_CLI_SHORTCUT_MAP.get(normalized) ?? null; +} + +export function getProviderCLIAliases(provider: CLIProxyProvider): readonly string[] { + return PROVIDER_CAPABILITIES[provider].cliAliases ?? []; +} + export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities { return PROVIDER_CAPABILITIES[provider]; } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 75fe40d6..429a41e0 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -33,6 +33,7 @@ const CHANNEL_TO_PROVIDER: Record = { claude: 'claude', gemini: 'gemini', codex: 'codex', + xai: 'xai', qwen: 'qwen', iflow: 'iflow', kimi: 'kimi', diff --git a/src/cliproxy/types/provider-types.ts b/src/cliproxy/types/provider-types.ts index e96fade7..e241d259 100644 --- a/src/cliproxy/types/provider-types.ts +++ b/src/cliproxy/types/provider-types.ts @@ -6,6 +6,7 @@ export type CLIProxyProvider = | 'gemini' | 'codex' + | 'xai' | 'agy' | 'qwen' | 'iflow' diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index e5e269b9..1623b9b2 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -1,5 +1,9 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; -import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderCLIAliases, + getProviderDisplayName, +} from '../cliproxy/provider-capabilities'; export type HelpTopicName = | 'profiles' @@ -200,6 +204,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO { gemini: 'Google Gemini via CLIProxy OAuth', codex: 'OpenAI Codex via CLIProxy OAuth', + xai: 'xAI Grok via CLIProxy OAuth (alias: grok)', agy: 'Antigravity via CLIProxy OAuth', qwen: 'Qwen Code via CLIProxy; account linking unsupported', iflow: 'iFlow via CLIProxy OAuth', @@ -216,6 +221,15 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO }) ); +/** Additional top-level CLI aliases; canonical provider lists remain alias-free. */ +export const BUILTIN_PROVIDER_ALIAS_SHORTCUTS: readonly ShortcutEntry[] = + CLIPROXY_PROVIDER_IDS.flatMap((provider) => + getProviderCLIAliases(provider).map((name) => ({ + name, + summary: `Alias for ${getProviderDisplayName(provider)} provider shortcut`, + })) + ); + export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [ { name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' }, { diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 1838db9c..6d6816cd 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -2,6 +2,7 @@ import ProfileDetector from '../auth/profile-detector'; import { API_SUBCOMMANDS, AUTH_SUBCOMMANDS, + BUILTIN_PROVIDER_ALIAS_SHORTCUTS, BUILTIN_PROVIDER_SHORTCUTS, CLEANUP_FLAGS, COMMAND_FLAG_SUGGESTIONS, @@ -65,6 +66,7 @@ function getTopLevelSuggestions(): CompletionSuggestion[] { ...getPublicRootCommandTokens().map((value) => suggestion(value)), ...ROOT_COMMAND_FLAGS.map((value) => suggestion(value)), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), + ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), ...getDynamicProfileSuggestions(), ]; } @@ -262,6 +264,7 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg ...getProfileNames('settings'), ...getProfileNames('accounts'), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => entry.name), + ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => entry.name), ]), COMMAND_FLAG_SUGGESTIONS.env ); @@ -280,7 +283,11 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'] ); default: - if (BUILTIN_PROVIDER_SHORTCUTS.some((entry) => entry.name === command)) { + if ( + [...BUILTIN_PROVIDER_SHORTCUTS, ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS].some( + (entry) => entry.name === command + ) + ) { if (command === 'agy') return completeSubcommands([], [...PROVIDER_FLAGS, '--accept-agr-risk']); if (command === 'kiro') { diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index bd511b53..d93ca244 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -63,7 +63,7 @@ export function generateYamlWithComments(config: UnifiedConfig): string { // CLIProxy section lines.push('# ----------------------------------------------------------------------------'); - lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)'); + lines.push('# CLIProxy: OAuth-based providers (gemini, codex, xai/grok, agy, qwen, iflow)'); lines.push('# Each variant can reference a *.settings.json file for custom env vars.'); lines.push('# Edit the settings file directly to customize model or other settings.'); lines.push( diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index 32b4c897..a47bed12 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -9,7 +9,7 @@ */ import { info } from '../utils/ui'; -import { isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { resolveCLIProxyProviderShortcut } from '../cliproxy/provider-capabilities'; import { isCopilotSubcommandToken } from '../copilot/constants'; import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { isCacheStale } from '../utils/update-checker'; @@ -111,14 +111,11 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise 1 && - (args.includes('--help') || args.includes('-h')) - ) { + const shortcutProvider = + typeof firstArg === 'string' ? resolveCLIProxyProviderShortcut(firstArg) : null; + if (shortcutProvider && args.length > 1 && (args.includes('--help') || args.includes('-h'))) { const { showProviderShortcutHelp } = await import('../commands/help-command'); - await showProviderShortcutHelp(firstArg); + await showProviderShortcutHelp(shortcutProvider); return true; } diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 8769615b..c028453a 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -94,12 +94,20 @@ describe('ProfileDetector', () => { }); it('should detect newly added cliproxy providers', () => { + expect(detector.detectProfileType('xai').provider).toBe('xai'); expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); expect(detector.detectProfileType('kilo').provider).toBe('kilo'); expect(detector.detectProfileType('qoder').provider).toBe('qoder'); }); + it('should resolve the grok CLI alias to canonical xai routing', () => { + const result = detector.detectProfileType('grok'); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('xai'); + expect(result.provider).toBe('xai'); + }); + it('should detect settings-based profile from unified config', () => { const settingsPath = path.join(tempDir, 'glm.settings.json'); fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index b98ae57a..a5ff4eff 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -82,6 +82,8 @@ describe('completion backend', () => { expect(values).toContain('cursor'); expect(values).toContain('copilot'); expect(values).toContain('gemini'); + expect(values).toContain('xai'); + expect(values).toContain('grok'); expect(values).toContain('gitlab'); expect(values).toContain('codebuddy'); expect(values).toContain('kilo'); @@ -94,6 +96,11 @@ describe('completion backend', () => { expect(values).not.toContain('--uninstall'); }); + test('offers provider flags for both xai and grok shortcuts', () => { + expect(suggestionValues(['xai'])).toEqual(expect.arrayContaining(['--auth', '--accounts'])); + expect(suggestionValues(['grok'])).toEqual(expect.arrayContaining(['--auth', '--accounts'])); + }); + test('suggests help topics from the help command', () => { const values = suggestionValues(['help']); expect(values).toContain('profiles'); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 739f4488..ba8ee389 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -57,6 +57,8 @@ describe('help command parity', () => { expect(rendered.includes('ccs help kiro')).toBe(true); expect(rendered.includes('gemini')).toBe(true); expect(rendered.includes('codex')).toBe(true); + expect(rendered.includes('xai')).toBe(true); + expect(rendered.includes('alias: grok')).toBe(true); expect(rendered.includes('ghcp')).toBe(true); expect(rendered.includes('gitlab')).toBe(true); expect(rendered.includes('codebuddy')).toBe(true); diff --git a/ui/public/assets/providers/xai.svg b/ui/public/assets/providers/xai.svg new file mode 100644 index 00000000..f5a97161 --- /dev/null +++ b/ui/public/assets/providers/xai.svg @@ -0,0 +1,4 @@ + + xAI Grok + + diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 77e8a2fb..837e239a 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -1,7 +1,7 @@ /** * Add Account Dialog Component * Uses /start-url to get OAuth URL + polls for completion via management API. - * For Device Code flows (ghcp, qwen, kiro): Uses /start endpoint which spawns CLIProxy + * For verification-code flows: Uses /start endpoint which spawns CLIProxy * binary and emits WebSocket events. DeviceCodeDialog handles user code display. * Shows auth URL + callback paste field. Polling auto-closes on success. * For Kiro: Also shows "Import from IDE" option. diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 1fca25f4..8c2141d4 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -17,6 +17,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'claude', 'gemini', 'codex', + 'xai', 'cursor', 'gitlab', 'codebuddy', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 2a6475ee..97df35d4 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -382,6 +382,68 @@ export const MODEL_CATALOGS: Record = { }, ], }, + xai: { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-build-0.1', + models: [ + { + id: 'grok-build-0.1', + name: 'Grok Build 0.1', + description: 'Fast coding model for agentic software engineering workflows', + presetMapping: { + default: 'grok-build-0.1', + opus: 'grok-4.5', + sonnet: 'grok-build-0.1', + haiku: 'grok-composer-2.5-fast', + }, + }, + { + id: 'grok-4.5', + name: 'Grok 4.5', + description: 'Frontier model for coding, engineering, and agentic workflows', + }, + { + id: 'grok-4.3', + name: 'Grok 4.3', + description: 'General-purpose Grok model with a one-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-0309-reasoning', + name: 'Grok 4.20 0309 Reasoning', + description: 'Reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-0309-non-reasoning', + name: 'Grok 4.20 0309 Non Reasoning', + description: 'Non-reasoning model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-4.20-multi-agent-0309', + name: 'Grok 4.20 Multi Agent 0309', + description: 'Multi-agent model with a two-million-token context window', + extendedContext: true, + }, + { + id: 'grok-3-mini', + name: 'Grok 3 Mini', + description: 'Compact reasoning model', + }, + { + id: 'grok-3-mini-fast', + name: 'Grok 3 Mini Fast', + description: 'Faster compact reasoning model', + }, + { + id: 'grok-composer-2.5-fast', + name: 'Grok Composer 2.5 Fast', + description: 'Fast Composer model for lightweight coding tasks', + }, + ], + }, qwen: { provider: 'qwen', displayName: 'Qwen', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index cda84a9b..d2adf7c6 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -98,6 +98,7 @@ export const PROVIDER_ASSETS: Partial> = { gemini: '/assets/providers/gemini-color.svg', agy: '/assets/providers/agy.png', codex: '/assets/providers/codex-color.svg', + xai: '/assets/providers/xai.svg', qwen: '/assets/providers/qwen-color.svg', iflow: '/assets/providers/iflow.png', kiro: '/assets/providers/kiro.png', @@ -128,6 +129,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record = { gemini: '#4285F4', agy: '#f3722c', codex: '#10a37f', + xai: '#111827', openai: '#111827', vertex: '#4285F4', iflow: '#f94144', @@ -370,6 +373,7 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly { + it('mirrors the CLIProxyAPI text catalog and default tier routing', () => { + const catalog = MODEL_CATALOGS.xai; + const ids = catalog.models.map((model) => model.id); + const defaultModel = catalog.models.find((model) => model.id === catalog.defaultModel); + + expect(catalog.displayName).toBe('xAI (Grok)'); + expect(catalog.defaultModel).toBe('grok-build-0.1'); + expect(ids).toEqual([ + 'grok-build-0.1', + 'grok-4.5', + 'grok-4.3', + 'grok-4.20-0309-reasoning', + 'grok-4.20-0309-non-reasoning', + 'grok-4.20-multi-agent-0309', + 'grok-3-mini', + 'grok-3-mini-fast', + 'grok-composer-2.5-fast', + ]); + expect(defaultModel?.presetMapping).toEqual({ + default: 'grok-build-0.1', + opus: 'grok-4.5', + sonnet: 'grok-build-0.1', + haiku: 'grok-composer-2.5-fast', + }); + }); +}); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 8d11c520..8bcec84a 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CLIPROXY_PROVIDER_SECTIONS, + CLIPROXY_PROVIDERS, CORE_CLIPROXY_PROVIDERS, formatRequestedUpstreamModelRules, getProviderDescription, @@ -19,6 +20,7 @@ import { PROVIDER_COLORS, variantUsesPlusExtraProvider, } from '@/lib/provider-config'; +import { PROVIDERS as WIZARD_PROVIDERS } from '@/components/setup/wizard/constants'; describe('provider model mapping helpers', () => { it('parses requested=upstream rules into stored upstream+alias pairs', () => { @@ -61,6 +63,7 @@ describe('provider presentation metadata', () => { expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']); expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini'); expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi'); + expect(CORE_CLIPROXY_PROVIDERS).toContain('xai'); expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([ 'kiro', 'ghcp', @@ -76,6 +79,12 @@ describe('provider presentation metadata', () => { expect(isPlusExtraProvider('gemini')).toBe(false); }); + it('keeps every non-deprecated canonical provider available in the setup wizard', () => { + expect(WIZARD_PROVIDERS.map((provider) => provider.id).sort()).toEqual( + CLIPROXY_PROVIDERS.filter((provider) => provider !== 'ghcp').sort() + ); + }); + it('groups provider-backed data by shared section metadata', () => { const grouped = groupProvidersBySection( [ @@ -96,6 +105,7 @@ describe('provider presentation metadata', () => { expect(isDeviceCodeProvider('cursor')).toBe(false); expect(isDeviceCodeProvider('ghcp')).toBe(true); expect(isDeviceCodeProvider('qwen')).toBe(true); + expect(isDeviceCodeProvider('xai')).toBe(true); }); it('detects plus-extra providers inside composite variants', () => { @@ -120,6 +130,7 @@ describe('provider presentation metadata', () => { }); it.each([ + ['xai', 'xAI (Grok)', 'Grok coding and reasoning models', '/assets/providers/xai.svg'], ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'], ['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'], [ @@ -153,10 +164,15 @@ describe('provider presentation metadata', () => { textClass: 'text-rose-600', letter: 'K', }); + expect(getProviderFallbackVisual('xai')).toEqual({ + textClass: 'text-slate-900', + letter: 'X', + }); expect(PROVIDER_COLORS.cursor).toBe('#111827'); expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26'); expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB'); expect(PROVIDER_COLORS.kilo).toBe('#E11D48'); + expect(PROVIDER_COLORS.xai).toBe('#111827'); }); }); From f77ed33cc70f8cd6b7ffb92f235eefb7b0386309 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 17:13:40 -0400 Subject: [PATCH 29/35] fix(bar): show Claude and Codex pool quota --- .../routes/bar-pool-account-quota-adapter.ts | 213 ++++++++++++++++++ src/web-server/routes/bar-routes.ts | 18 +- .../bar-pool-account-quota-adapter.test.ts | 213 ++++++++++++++++++ .../bar-pool-account-quota-cache.test.ts | 120 ++++++++++ 4 files changed, 555 insertions(+), 9 deletions(-) create mode 100644 src/web-server/routes/bar-pool-account-quota-adapter.ts create mode 100644 tests/unit/web-server/bar-pool-account-quota-adapter.test.ts create mode 100644 tests/unit/web-server/bar-pool-account-quota-cache.test.ts diff --git a/src/web-server/routes/bar-pool-account-quota-adapter.ts b/src/web-server/routes/bar-pool-account-quota-adapter.ts new file mode 100644 index 00000000..09e3eab0 --- /dev/null +++ b/src/web-server/routes/bar-pool-account-quota-adapter.ts @@ -0,0 +1,213 @@ +/** + * Bar-only adapter for CLIProxy pool-account quota. + * + * Bar's cache and row builder consume the legacy Antigravity QuotaResult shape. + * Claude and Codex expose richer window-based results, so this adapter dispatches + * to their single-account fetchers and collapses the binding core window into + * the existing percentage/reset fields. Other providers retain the legacy + * fetcher's behavior, including Antigravity and quota_not_supported results. + */ + +import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher'; +import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher'; +import { + getCachedQuota, + invalidateQuotaCache, + setCachedQuota, +} from '../../cliproxy/quota/quota-response-cache'; +import { + buildClaudeCoreUsageSummary, + fetchClaudeQuota, +} from '../../cliproxy/quota/quota-fetcher-claude'; +import { + buildCodexCoreUsageSummary, + fetchCodexQuota, +} from '../../cliproxy/quota/quota-fetcher-codex'; +import type { + ClaudeQuotaResult, + CodexQuotaResult, + QuotaErrorMetadata, +} from '../../cliproxy/quota/quota-types'; + +interface BindingWindow { + name: string; + displayName: string; + remainingPercent: number; + resetAt: string | null; +} + +interface ProviderQuotaResult extends QuotaErrorMetadata { + success: boolean; + lastUpdated: number; + error?: string; + accountId?: string; + needsReauth?: boolean; +} + +type RawWindowQuotaResult = ClaudeQuotaResult | CodexQuotaResult; + +const rawQuotaByNormalizedResult = new WeakMap(); + +export interface BarPoolAccountQuotaFetcherDeps { + fetchLegacyAccountQuota: (provider: CLIProxyProvider, accountId: string) => Promise; + fetchClaudeQuota: (accountId: string) => Promise; + fetchCodexQuota: (accountId: string) => Promise; +} + +function normalizeRemainingPercent(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function isPresent(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} + +function resetTimestamp(resetAt: string | null): number { + if (!resetAt) return Number.POSITIVE_INFINITY; + const timestamp = Date.parse(resetAt); + return Number.isFinite(timestamp) ? timestamp : Number.POSITIVE_INFINITY; +} + +function pickBindingWindow(windows: BindingWindow[]): BindingWindow | null { + return ( + windows + .filter((window) => Number.isFinite(window.remainingPercent)) + .sort( + (left, right) => + left.remainingPercent - right.remainingPercent || + resetTimestamp(left.resetAt) - resetTimestamp(right.resetAt) + )[0] ?? null + ); +} + +function normalizeResult( + result: ProviderQuotaResult, + bindingWindow: BindingWindow | null +): QuotaResult { + return { + success: result.success, + models: + result.success && bindingWindow + ? [ + { + name: bindingWindow.name, + displayName: bindingWindow.displayName, + percentage: normalizeRemainingPercent(bindingWindow.remainingPercent), + resetTime: bindingWindow.resetAt, + }, + ] + : [], + lastUpdated: result.lastUpdated, + httpStatus: result.httpStatus, + errorCode: result.errorCode, + errorDetail: result.errorDetail, + isForbidden: result.isForbidden, + error: result.error, + actionHint: result.actionHint, + retryable: result.retryable, + needsReauth: result.needsReauth, + accountId: result.accountId, + }; +} + +function normalizeClaudeQuota(result: ClaudeQuotaResult): QuotaResult { + const coreUsage = + result.coreUsage?.fiveHour || result.coreUsage?.weekly + ? result.coreUsage + : buildClaudeCoreUsageSummary(result.windows); + const bindingWindow = pickBindingWindow( + [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ + name: window.rateLimitType, + displayName: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + })) + ); + + const normalized = normalizeResult(result, bindingWindow); + rawQuotaByNormalizedResult.set(normalized, result); + return normalized; +} + +function normalizeCodexQuota(result: CodexQuotaResult): QuotaResult { + const coreUsage = + result.coreUsage?.fiveHour || result.coreUsage?.weekly + ? result.coreUsage + : buildCodexCoreUsageSummary(result.windows); + const bindingWindow = pickBindingWindow( + [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ + name: window.label, + displayName: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + })) + ); + + const normalized = normalizeResult(result, bindingWindow); + rawQuotaByNormalizedResult.set(normalized, result); + return normalized; +} + +function isWindowQuotaResult(value: unknown): value is RawWindowQuotaResult { + return ( + typeof value === 'object' && + value !== null && + 'windows' in value && + Array.isArray((value as { windows?: unknown }).windows) + ); +} + +export function getCachedBarPoolAccountQuota( + provider: CLIProxyProvider | string, + accountId: string +): T | null { + const cached = getCachedQuota(provider, accountId); + if (!cached) return null; + if (provider === 'claude' && isWindowQuotaResult(cached)) { + return normalizeClaudeQuota(cached as ClaudeQuotaResult) as T; + } + if (provider === 'codex' && isWindowQuotaResult(cached)) { + return normalizeCodexQuota(cached as CodexQuotaResult) as T; + } + return cached as T; +} + +export function setCachedBarPoolAccountQuota( + provider: CLIProxyProvider | string, + accountId: string, + data: T +): void { + const raw = + typeof data === 'object' && data !== null + ? rawQuotaByNormalizedResult.get(data as unknown as QuotaResult) + : undefined; + setCachedQuota(provider, accountId, raw ?? data); +} + +export function invalidateCachedBarPoolAccountQuota( + provider: CLIProxyProvider | string, + accountId: string +): void { + invalidateQuotaCache(provider, accountId); +} + +export function createBarPoolAccountQuotaFetcher( + deps: BarPoolAccountQuotaFetcherDeps +): (provider: CLIProxyProvider, accountId: string) => Promise { + return async (provider, accountId) => { + if (provider === 'claude') { + return normalizeClaudeQuota(await deps.fetchClaudeQuota(accountId)); + } + if (provider === 'codex') { + return normalizeCodexQuota(await deps.fetchCodexQuota(accountId)); + } + return deps.fetchLegacyAccountQuota(provider, accountId); + }; +} + +export const fetchBarPoolAccountQuota = createBarPoolAccountQuotaFetcher({ + fetchLegacyAccountQuota: fetchAccountQuota, + fetchClaudeQuota, + fetchCodexQuota, +}); diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index 3acc5fb9..f68328fc 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -631,11 +631,11 @@ export function createBarRouter(deps: BarRouterDeps): Router { import { getAllAccountsSummary } from '../../cliproxy/accounts/query'; import { - getCachedQuota, - setCachedQuota, - invalidateQuotaCache, -} from '../../cliproxy/quota/quota-response-cache'; -import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher'; + fetchBarPoolAccountQuota, + getCachedBarPoolAccountQuota, + invalidateCachedBarPoolAccountQuota, + setCachedBarPoolAccountQuota, +} from './bar-pool-account-quota-adapter'; import { getTodayCostByAccount } from '../usage/data-aggregator'; import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader'; import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator'; @@ -644,10 +644,10 @@ import { getNativeAccountRows, getCachedNativeAccountRows } from '../usage/nativ /** Production bar router — wired to real dependencies */ const barRouter: Router = createBarRouter({ getAllAccountsSummary, - getCachedQuota, - setCachedQuota, - invalidateQuotaCache, - fetchAccountQuota, + getCachedQuota: getCachedBarPoolAccountQuota, + setCachedQuota: setCachedBarPoolAccountQuota, + invalidateQuotaCache: invalidateCachedBarPoolAccountQuota, + fetchAccountQuota: fetchBarPoolAccountQuota, getTodayCostByAccount, loadCliproxyDetails: loadCliproxySnapshotDetails, loadDailyUsage: () => getCachedDailyData(), diff --git a/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts b/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts new file mode 100644 index 00000000..01da3de3 --- /dev/null +++ b/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'bun:test'; +import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher'; +import type { + ClaudeQuotaResult, + CodexQuotaResult, +} from '../../../src/cliproxy/quota/quota-types'; +import { + createBarPoolAccountQuotaFetcher, + type BarPoolAccountQuotaFetcherDeps, +} from '../../../src/web-server/routes/bar-pool-account-quota-adapter'; + +function agyResult(overrides: Partial = {}): QuotaResult { + return { + success: true, + models: [{ name: 'gemini-pro', percentage: 81, resetTime: null }], + lastUpdated: 1, + ...overrides, + }; +} + +function claudeResult(overrides: Partial = {}): ClaudeQuotaResult { + return { + success: true, + windows: [], + coreUsage: { + fiveHour: { + rateLimitType: 'five_hour', + label: '5h usage limit', + remainingPercent: 72, + resetAt: '2026-07-18T16:00:00.000Z', + status: 'allowed', + }, + weekly: { + rateLimitType: 'seven_day', + label: 'Weekly usage limit', + remainingPercent: 45, + resetAt: '2026-07-21T12:00:00.000Z', + status: 'allowed_warning', + }, + }, + lastUpdated: 2, + accountId: 'claude@example.com', + ...overrides, + }; +} + +function codexResult(overrides: Partial = {}): CodexQuotaResult { + return { + success: true, + windows: [], + coreUsage: { + fiveHour: { + label: 'Primary', + remainingPercent: 36, + resetAfterSeconds: 3600, + resetAt: '2026-07-18T14:00:00.000Z', + }, + weekly: { + label: 'Secondary', + remainingPercent: 64, + resetAfterSeconds: 259200, + resetAt: '2026-07-21T13:00:00.000Z', + }, + }, + planType: 'pro', + lastUpdated: 3, + accountId: 'codex@example.com', + ...overrides, + }; +} + +function createDeps( + overrides: Partial = {} +): BarPoolAccountQuotaFetcherDeps { + return { + fetchLegacyAccountQuota: async () => agyResult(), + fetchClaudeQuota: async () => claudeResult(), + fetchCodexQuota: async () => codexResult(), + ...overrides, + }; +} + +describe('Bar pool account quota adapter', () => { + it('maps the most restrictive Claude core window to percentage and reset', async () => { + const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps()); + + const result = await fetchQuota('claude', 'claude@example.com'); + + expect(result.success).toBe(true); + expect(result.models).toEqual([ + { + name: 'seven_day', + displayName: 'Weekly usage limit', + percentage: 45, + resetTime: '2026-07-21T12:00:00.000Z', + }, + ]); + expect(result.accountId).toBe('claude@example.com'); + }); + + it('maps the most restrictive Codex core window to percentage and reset', async () => { + const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps()); + + const result = await fetchQuota('codex', 'codex@example.com'); + + expect(result.success).toBe(true); + expect(result.models).toEqual([ + { + name: 'Primary', + displayName: 'Primary', + percentage: 36, + resetTime: '2026-07-18T14:00:00.000Z', + }, + ]); + expect(result.accountId).toBe('codex@example.com'); + }); + + it('preserves Claude transient failure metadata', async () => { + const fetchQuota = createBarPoolAccountQuotaFetcher( + createDeps({ + fetchClaudeQuota: async () => + claudeResult({ + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + error: 'Claude OAuth usage request timeout', + errorCode: 'network_timeout', + retryable: true, + }), + }) + ); + + const result = await fetchQuota('claude', 'claude@example.com'); + + expect(result).toMatchObject({ + success: false, + models: [], + error: 'Claude OAuth usage request timeout', + errorCode: 'network_timeout', + retryable: true, + }); + }); + + it('preserves Codex reauthentication state', async () => { + const fetchQuota = createBarPoolAccountQuotaFetcher( + createDeps({ + fetchCodexQuota: async () => + codexResult({ + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + error: 'Token expired or invalid', + errorCode: 'reauth_required', + httpStatus: 401, + needsReauth: true, + retryable: false, + }), + }) + ); + + const result = await fetchQuota('codex', 'codex@example.com'); + + expect(result).toMatchObject({ + success: false, + models: [], + errorCode: 'reauth_required', + httpStatus: 401, + needsReauth: true, + retryable: false, + }); + }); + + it('delegates Antigravity unchanged to the legacy fetcher', async () => { + const expected = agyResult(); + const calls: Array<[string, string]> = []; + const fetchQuota = createBarPoolAccountQuotaFetcher( + createDeps({ + fetchLegacyAccountQuota: async (provider, accountId) => { + calls.push([provider, accountId]); + return expected; + }, + }) + ); + + const result = await fetchQuota('agy', 'agy@example.com'); + + expect(result).toBe(expected); + expect(calls).toEqual([['agy', 'agy@example.com']]); + }); + + it('preserves legacy unsupported-provider semantics', async () => { + const fetchQuota = createBarPoolAccountQuotaFetcher( + createDeps({ + fetchLegacyAccountQuota: async (provider) => + agyResult({ + success: false, + models: [], + error: `Quota not supported for provider: ${provider}`, + errorCode: 'quota_not_supported', + }), + }) + ); + + const result = await fetchQuota('kiro', 'kiro-account'); + + expect(result).toMatchObject({ + success: false, + models: [], + errorCode: 'quota_not_supported', + }); + }); + +}); diff --git a/tests/unit/web-server/bar-pool-account-quota-cache.test.ts b/tests/unit/web-server/bar-pool-account-quota-cache.test.ts new file mode 100644 index 00000000..9a169377 --- /dev/null +++ b/tests/unit/web-server/bar-pool-account-quota-cache.test.ts @@ -0,0 +1,120 @@ +import { expect, it } from 'bun:test'; +import express from 'express'; +import type { Server } from 'node:http'; +import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher'; +import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types'; +import { + clearQuotaCache, + getCachedQuota, +} from '../../../src/cliproxy/quota/quota-response-cache'; +import { + createBarPoolAccountQuotaFetcher, + getCachedBarPoolAccountQuota, + invalidateCachedBarPoolAccountQuota, + setCachedBarPoolAccountQuota, +} from '../../../src/web-server/routes/bar-pool-account-quota-adapter'; +import { + createBarRouter, + resetForceFreshDebounce, +} from '../../../src/web-server/routes/bar-routes'; + +it('stores normalized Claude quota in the existing Bar cache', async () => { + clearQuotaCache(); + let fetchCount = 0; + const fetchQuota = createBarPoolAccountQuotaFetcher({ + fetchLegacyAccountQuota: async () => { + throw new Error('Legacy fetcher should not be called'); + }, + fetchClaudeQuota: async () => { + fetchCount += 1; + return { + success: true, + windows: [], + coreUsage: { + fiveHour: { + rateLimitType: 'five_hour', + label: '5h usage limit', + remainingPercent: 72, + resetAt: '2026-07-18T16:00:00.000Z', + status: 'allowed', + }, + weekly: { + rateLimitType: 'seven_day', + label: 'Weekly usage limit', + remainingPercent: 45, + resetAt: '2026-07-21T12:00:00.000Z', + status: 'allowed_warning', + }, + }, + lastUpdated: 2, + accountId: 'claude@example.com', + }; + }, + fetchCodexQuota: async () => { + throw new Error('Codex fetcher should not be called'); + }, + }); + const app = express(); + app.use( + '/api/bar', + createBarRouter({ + getAllAccountsSummary: () => + ({ + claude: [ + { + id: 'claude@example.com', + provider: 'claude', + nickname: 'Claude pool', + paused: false, + isDefault: true, + tokenFile: 'claude-claude_example_com.json', + createdAt: '2026-07-18T00:00:00.000Z', + }, + ], + }) as never, + getCachedQuota: getCachedBarPoolAccountQuota, + setCachedQuota: setCachedBarPoolAccountQuota, + invalidateQuotaCache: invalidateCachedBarPoolAccountQuota, + fetchAccountQuota: fetchQuota, + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + }) + ); + + resetForceFreshDebounce(); + const server = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No server address'); + const url = `http://127.0.0.1:${address.port}/api/bar/summary`; + const first = (await (await fetch(url)).json()) as Array>; + const second = (await (await fetch(url)).json()) as Array>; + + expect(fetchCount).toBe(1); + expect(first[0]).toMatchObject({ + quota_percentage: 45, + next_reset: '2026-07-21T12:00:00.000Z', + quotaStatus: 'ok', + cached: false, + }); + expect(second[0]).toMatchObject({ + quota_percentage: 45, + next_reset: '2026-07-21T12:00:00.000Z', + quotaStatus: 'ok', + cached: true, + }); + const sharedCache = getCachedQuota('claude', 'claude@example.com'); + expect(sharedCache?.windows).toEqual([]); + expect((sharedCache as ClaudeQuotaResult & Partial)?.models).toBeUndefined(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + clearQuotaCache(); + } +}); From 8996c92fda4c71b702acf505d1cee4300586cac9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 17:28:36 -0400 Subject: [PATCH 30/35] fix(bar): preserve quota cache and reset semantics --- .../routes/bar-pool-account-quota-adapter.ts | 64 ++++++---- .../bar-pool-account-quota-adapter.test.ts | 15 ++- ...ar-pool-account-quota-cache-policy.test.ts | 111 ++++++++++++++++++ .../bar-pool-account-quota-cache.test.ts | 4 +- 4 files changed, 163 insertions(+), 31 deletions(-) create mode 100644 tests/unit/web-server/bar-pool-account-quota-cache-policy.test.ts diff --git a/src/web-server/routes/bar-pool-account-quota-adapter.ts b/src/web-server/routes/bar-pool-account-quota-adapter.ts index 09e3eab0..76b5348d 100644 --- a/src/web-server/routes/bar-pool-account-quota-adapter.ts +++ b/src/web-server/routes/bar-pool-account-quota-adapter.ts @@ -29,6 +29,7 @@ import type { CodexQuotaResult, QuotaErrorMetadata, } from '../../cliproxy/quota/quota-types'; +import { shouldCacheQuotaResult } from './cliproxy-stats-routes/quota-helpers'; interface BindingWindow { name: string; @@ -81,9 +82,22 @@ function pickBindingWindow(windows: BindingWindow[]): BindingWindow | null { ); } +function pickEarliestReset(windows: BindingWindow[]): string | null { + return ( + windows + .map((window) => ({ + resetAt: window.resetAt, + timestamp: resetTimestamp(window.resetAt), + })) + .filter((reset) => Number.isFinite(reset.timestamp)) + .sort((left, right) => left.timestamp - right.timestamp)[0]?.resetAt ?? null + ); +} + function normalizeResult( result: ProviderQuotaResult, - bindingWindow: BindingWindow | null + bindingWindow: BindingWindow | null, + earliestReset: string | null ): QuotaResult { return { success: result.success, @@ -94,7 +108,7 @@ function normalizeResult( name: bindingWindow.name, displayName: bindingWindow.displayName, percentage: normalizeRemainingPercent(bindingWindow.remainingPercent), - resetTime: bindingWindow.resetAt, + resetTime: earliestReset, }, ] : [], @@ -116,16 +130,15 @@ function normalizeClaudeQuota(result: ClaudeQuotaResult): QuotaResult { result.coreUsage?.fiveHour || result.coreUsage?.weekly ? result.coreUsage : buildClaudeCoreUsageSummary(result.windows); - const bindingWindow = pickBindingWindow( - [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ - name: window.rateLimitType, - displayName: window.label, - remainingPercent: window.remainingPercent, - resetAt: window.resetAt, - })) - ); + const windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ + name: window.rateLimitType, + displayName: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + })); + const bindingWindow = pickBindingWindow(windows); - const normalized = normalizeResult(result, bindingWindow); + const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows)); rawQuotaByNormalizedResult.set(normalized, result); return normalized; } @@ -135,16 +148,15 @@ function normalizeCodexQuota(result: CodexQuotaResult): QuotaResult { result.coreUsage?.fiveHour || result.coreUsage?.weekly ? result.coreUsage : buildCodexCoreUsageSummary(result.windows); - const bindingWindow = pickBindingWindow( - [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ - name: window.label, - displayName: window.label, - remainingPercent: window.remainingPercent, - resetAt: window.resetAt, - })) - ); + const windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({ + name: window.label, + displayName: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + })); + const bindingWindow = pickBindingWindow(windows); - const normalized = normalizeResult(result, bindingWindow); + const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows)); rawQuotaByNormalizedResult.set(normalized, result); return normalized; } @@ -182,7 +194,17 @@ export function setCachedBarPoolAccountQuota( typeof data === 'object' && data !== null ? rawQuotaByNormalizedResult.get(data as unknown as QuotaResult) : undefined; - setCachedQuota(provider, accountId, raw ?? data); + const cacheValue = raw ?? data; + if ( + typeof cacheValue === 'object' && + cacheValue !== null && + 'success' in cacheValue && + typeof cacheValue.success === 'boolean' && + !shouldCacheQuotaResult(cacheValue as ProviderQuotaResult) + ) { + return; + } + setCachedQuota(provider, accountId, cacheValue); } export function invalidateCachedBarPoolAccountQuota( diff --git a/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts b/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts index 01da3de3..1aa5dd29 100644 --- a/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts +++ b/tests/unit/web-server/bar-pool-account-quota-adapter.test.ts @@ -57,7 +57,7 @@ function codexResult(overrides: Partial = {}): CodexQuotaResul }, weekly: { label: 'Secondary', - remainingPercent: 64, + remainingPercent: 24, resetAfterSeconds: 259200, resetAt: '2026-07-21T13:00:00.000Z', }, @@ -81,7 +81,7 @@ function createDeps( } describe('Bar pool account quota adapter', () => { - it('maps the most restrictive Claude core window to percentage and reset', async () => { + it('maps Claude percentage from the most restrictive window and reset from the earliest', async () => { const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps()); const result = await fetchQuota('claude', 'claude@example.com'); @@ -92,13 +92,13 @@ describe('Bar pool account quota adapter', () => { name: 'seven_day', displayName: 'Weekly usage limit', percentage: 45, - resetTime: '2026-07-21T12:00:00.000Z', + resetTime: '2026-07-18T16:00:00.000Z', }, ]); expect(result.accountId).toBe('claude@example.com'); }); - it('maps the most restrictive Codex core window to percentage and reset', async () => { + it('maps Codex percentage from the most restrictive window and reset from the earliest', async () => { const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps()); const result = await fetchQuota('codex', 'codex@example.com'); @@ -106,9 +106,9 @@ describe('Bar pool account quota adapter', () => { expect(result.success).toBe(true); expect(result.models).toEqual([ { - name: 'Primary', - displayName: 'Primary', - percentage: 36, + name: 'Secondary', + displayName: 'Secondary', + percentage: 24, resetTime: '2026-07-18T14:00:00.000Z', }, ]); @@ -209,5 +209,4 @@ describe('Bar pool account quota adapter', () => { errorCode: 'quota_not_supported', }); }); - }); diff --git a/tests/unit/web-server/bar-pool-account-quota-cache-policy.test.ts b/tests/unit/web-server/bar-pool-account-quota-cache-policy.test.ts new file mode 100644 index 00000000..e6e474ed --- /dev/null +++ b/tests/unit/web-server/bar-pool-account-quota-cache-policy.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher'; +import type { + ClaudeQuotaResult, + CodexQuotaResult, +} from '../../../src/cliproxy/quota/quota-types'; +import { + clearQuotaCache, + getCachedQuota, +} from '../../../src/cliproxy/quota/quota-response-cache'; +import { + createBarPoolAccountQuotaFetcher, + getCachedBarPoolAccountQuota, + setCachedBarPoolAccountQuota, +} from '../../../src/web-server/routes/bar-pool-account-quota-adapter'; + +function createFetcher( + claudeResult: ClaudeQuotaResult, + codexResult: CodexQuotaResult +): (provider: 'claude' | 'codex', accountId: string) => Promise { + return createBarPoolAccountQuotaFetcher({ + fetchLegacyAccountQuota: async () => { + throw new Error('Legacy fetcher should not be called'); + }, + fetchClaudeQuota: async () => claudeResult, + fetchCodexQuota: async () => codexResult, + }); +} + +function claudeFailure(overrides: Partial = {}): ClaudeQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: 1, + accountId: 'claude@example.com', + ...overrides, + }; +} + +function codexFailure(overrides: Partial = {}): CodexQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'pro', + lastUpdated: 2, + accountId: 'codex@example.com', + ...overrides, + }; +} + +afterEach(() => { + clearQuotaCache(); +}); + +describe('Bar pool account quota cache policy', () => { + it('does not cache a retryable Claude timeout', async () => { + const raw = claudeFailure({ + error: 'Claude OAuth usage request timeout', + errorCode: 'network_timeout', + retryable: true, + }); + const fetchQuota = createFetcher(raw, codexFailure()); + const result = await fetchQuota('claude', 'claude@example.com'); + + setCachedBarPoolAccountQuota('claude', 'claude@example.com', result); + + expect(getCachedQuota('claude', 'claude@example.com')).toBeNull(); + expect(getCachedBarPoolAccountQuota('claude', 'claude@example.com')).toBeNull(); + }); + + it('does not cache a Codex 429 failure', async () => { + const raw = codexFailure({ + error: 'Codex usage API rate limited', + errorCode: 'rate_limited', + httpStatus: 429, + }); + const fetchQuota = createFetcher(claudeFailure(), raw); + const result = await fetchQuota('codex', 'codex@example.com'); + + setCachedBarPoolAccountQuota('codex', 'codex@example.com', result); + + expect(getCachedQuota('codex', 'codex@example.com')).toBeNull(); + expect(getCachedBarPoolAccountQuota('codex', 'codex@example.com')).toBeNull(); + }); + + it('caches a stable Codex reauthentication failure in raw provider shape', async () => { + const raw = codexFailure({ + error: 'Token expired or invalid', + errorCode: 'reauth_required', + httpStatus: 401, + needsReauth: true, + retryable: false, + }); + const fetchQuota = createFetcher(claudeFailure(), raw); + const result = await fetchQuota('codex', 'codex@example.com'); + + setCachedBarPoolAccountQuota('codex', 'codex@example.com', result); + + const shared = getCachedQuota('codex', 'codex@example.com'); + expect(shared).toEqual(raw); + expect((shared as CodexQuotaResult & Partial)?.models).toBeUndefined(); + expect(getCachedBarPoolAccountQuota('codex', 'codex@example.com')).toMatchObject({ + success: false, + models: [], + needsReauth: true, + httpStatus: 401, + }); + }); +}); diff --git a/tests/unit/web-server/bar-pool-account-quota-cache.test.ts b/tests/unit/web-server/bar-pool-account-quota-cache.test.ts index 9a169377..557dfeeb 100644 --- a/tests/unit/web-server/bar-pool-account-quota-cache.test.ts +++ b/tests/unit/web-server/bar-pool-account-quota-cache.test.ts @@ -100,13 +100,13 @@ it('stores normalized Claude quota in the existing Bar cache', async () => { expect(fetchCount).toBe(1); expect(first[0]).toMatchObject({ quota_percentage: 45, - next_reset: '2026-07-21T12:00:00.000Z', + next_reset: '2026-07-18T16:00:00.000Z', quotaStatus: 'ok', cached: false, }); expect(second[0]).toMatchObject({ quota_percentage: 45, - next_reset: '2026-07-21T12:00:00.000Z', + next_reset: '2026-07-18T16:00:00.000Z', quotaStatus: 'ok', cached: true, }); From 6dc199a0383b6f6ac8080ae358cba230f89b1be6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 17:46:53 -0400 Subject: [PATCH 31/35] fix(ci): isolate Bar command fast tests --- scripts/run-test-bucket.js | 1 + tests/unit/scripts/run-test-bucket.test.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index f20f1a14..1dd392c2 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -47,6 +47,7 @@ const slowTests = [ const fastJsTests = new Set(['tests/unit/flag-parsing-simple.test.js']); const isolatedTests = new Set([ + 'tests/unit/commands/bar-command.test.ts', 'tests/unit/targets/codex-adapter-exec.test.ts', 'tests/unit/targets/codex-adapter.test.ts', 'tests/unit/targets/droid-adapter.test.ts', diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js index fc60e328..608e71c8 100644 --- a/tests/unit/scripts/run-test-bucket.test.js +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -87,11 +87,13 @@ describe('run-test-bucket', () => { test('isolates known sticky mock suites outside src', () => { const runs = bucket.getBunRuns('fast', [ 'tests/unit/scripts/run-test-bucket.test.js', + 'tests/unit/commands/bar-command.test.ts', 'tests/unit/targets/target-registry.test.ts', ]); expect(runs.map((run) => run.label)).toEqual([ 'shared', + 'tests/unit/commands/bar-command.test.ts', 'tests/unit/targets/target-registry.test.ts', ]); }); From 66189250a3745882951046e961a113ace49d461e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Jul 2026 22:00:21 +0000 Subject: [PATCH 32/35] chore(release): 8.7.0-dev.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57a78795..a74e8f23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.10", + "version": "8.7.0-dev.11", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 4e4c949967f3aa112795e1bf0cc3ee8ebdbd5b3b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 19:04:07 -0400 Subject: [PATCH 33/35] fix(cliproxy): harden xAI provider contracts --- .../__tests__/model-catalog-compat.test.ts | 12 ++++++++ src/cliproxy/__tests__/model-catalog.test.js | 17 +++++++++++ .../oauth-process-error-parser.test.ts | 20 +++++++++++++ src/cliproxy/auth/oauth-process.ts | 21 +++++++++++++- src/cliproxy/model-catalog.ts | 5 +--- src/cliproxy/services/catalog-cache.ts | 8 +++-- ui/src/lib/model-catalogs.ts | 13 +++++---- .../unit/ui/lib/model-catalogs-xai.test.ts | 29 ++++++++++++++++++- 8 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index d7e4dfda..ff8bce9f 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -63,6 +63,18 @@ describe('model-catalog compatibility lookups', () => { }); }); + it('does not export extended-context capability from live xAI context length', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3', + context_length: 2_000_000, + }, + ]); + + expect(catalog?.models[0]?.extendedContext).toBeUndefined(); + }); + it('does not re-add stale static-only models when live catalog data is present', () => { const catalog = mergeCatalog('gemini', [ { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 29ac5d5d..4377ed32 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -87,6 +87,23 @@ describe('Model Catalog', () => { 'grok-composer-2.5-fast', ]); }); + + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { + const { MODEL_CATALOG, supportsExtendedContext } = modelCatalog; + + for (const model of MODEL_CATALOG.xai.models) { + assert.notStrictEqual( + model.extendedContext, + true, + `${model.id} must not advertise extended-context metadata` + ); + assert.strictEqual( + supportsExtendedContext('xai', model.id), + false, + `${model.id} must not advertise [1m] suffix support` + ); + } + }); }); describe('AGY models', () => { diff --git a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts index 9ae13c83..f89398c3 100644 --- a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts @@ -7,6 +7,7 @@ import { extractDeviceCodePrompt, getExpectedLocalCallback, getKiroBuilderIdSelectionInput, + resolveDeviceCodeVerificationUrl, validateManualCallbackUrl, } from '../oauth-process'; @@ -24,6 +25,18 @@ Then enter this code: ABCD-1234 verificationUrl: 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234', }); }); + + it('preserves a parsed xAI verification URL exactly', () => { + const parsedUrl = 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234&source=cliproxy'; + + expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', parsedUrl)).toBe(parsedUrl); + }); + + it('uses an xAI-specific device URL when upstream output omits the URL', () => { + expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', null)).toBe( + 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234' + ); + }); }); describe('oauth-process stderr parsing', () => { @@ -49,6 +62,13 @@ describe('oauth-process stderr parsing', () => { expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe('state mismatch'); }); + it('extracts the current upstream xAI authentication failure format', () => { + const stderr = + 'time="2026-07-18T00:00:00Z" level=error msg="xAI authentication failed: xai device code expired"'; + + expect(extractLikelyAuthFailureFromStderr('xai', stderr)).toBe('xai device code expired'); + }); + it('caps extracted message length to prevent noisy broadcasts', () => { const longSuffix = 'x'.repeat(400); const stderr = `level=error msg="Authentication failed: ${longSuffix}"`; diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index f94b77f7..edb3023b 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -143,6 +143,20 @@ export function extractDeviceCodePrompt(output: string): { }; } +export function resolveDeviceCodeVerificationUrl( + provider: CLIProxyProvider, + userCode: string, + parsedUrl: string | null +): string { + if (parsedUrl) { + return parsedUrl; + } + if (provider === 'xai') { + return `https://accounts.x.ai/oauth2/device?user_code=${encodeURIComponent(userCode)}`; + } + return 'https://github.com/login/device'; +} + export function isLoopbackHost(hostname: string): boolean { const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); return ( @@ -415,7 +429,11 @@ async function handleStdout( state.deviceCodeDisplayed = true; log(`Parsed device code: ${state.userCode}`); - const verificationUrl = devicePrompt.verificationUrl || 'https://github.com/login/device'; + const verificationUrl = resolveDeviceCodeVerificationUrl( + options.provider, + state.userCode, + devicePrompt.verificationUrl + ); // Emit device code event for WebSocket broadcast to UI const deviceCodePrompt: DeviceCodePrompt = { @@ -525,6 +543,7 @@ export function extractLikelyAuthFailureFromLogs( }); const providerPatterns: Partial> = { + xai: [/xai authentication failed:\s*(.+)/i], ghcp: [ /github copilot authentication failed:\s*(.+)/i, /failed to verify copilot access[^:]*:\s*(.+)/i, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index f698b590..f300fe13 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -325,7 +325,6 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', - extendedContext: true, thinking: { type: 'levels', levels: ['none', 'low', 'medium', 'high'], @@ -336,19 +335,16 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', - extendedContext: true, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { @@ -765,6 +761,7 @@ export function supportsThinking(provider: CLIProxyProvider, modelId: string): b * Returns true if model has extendedContext: true in catalog. */ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: string): boolean { + if (provider === 'xai') return false; const model = findModel(provider, modelId); return model?.extendedContext === true; } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 429a41e0..0c6511cd 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -215,13 +215,15 @@ function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefine } /** Map RemoteModelInfo to ModelEntry */ -function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry { +function mapRemoteToModelEntry(provider: CLIProxyProvider, remote: RemoteModelInfo): ModelEntry { const entry: ModelEntry = { id: remote.id, name: remote.display_name || remote.id, }; if (remote.description) entry.description = remote.description; - if (remote.context_length && remote.context_length >= 1_000_000) { + // xAI context length is inherent to the model ID; its API does not accept + // Claude's [1m] model suffix. + if (provider !== 'xai' && remote.context_length && remote.context_length >= 1_000_000) { entry.extendedContext = true; } const thinking = mapThinking(remote.thinking); @@ -264,7 +266,7 @@ export function mergeCatalog( const mergedModels: ModelEntry[] = []; for (const remote of filteredRemoteModels) { - const remoteEntry = mapRemoteToModelEntry(remote); + const remoteEntry = mapRemoteToModelEntry(provider, remote); const staticEntry = staticMap.get(remote.id.toLowerCase()); if (staticEntry) { const mergedThinking = remoteEntry.thinking diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 97df35d4..34c82ca4 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -407,25 +407,21 @@ export const MODEL_CATALOGS: Record = { id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', - extendedContext: true, }, { id: 'grok-3-mini', @@ -1124,7 +1120,8 @@ export function buildUiCatalog( provider: string, liveCatalog: ProviderCatalog | undefined ): ProviderCatalog | undefined { - const staticCatalog = MODEL_CATALOGS[provider.toLowerCase()]; + const normalizedProvider = provider.toLowerCase(); + const staticCatalog = MODEL_CATALOGS[normalizedProvider]; if (!liveCatalog || liveCatalog.models.length === 0) { return staticCatalog; } @@ -1145,7 +1142,10 @@ export function buildUiCatalog( issueUrl: staticModel?.issueUrl, deprecated: staticModel?.deprecated, deprecationReason: staticModel?.deprecationReason, - extendedContext: model.extendedContext ?? staticModel?.extendedContext, + extendedContext: + normalizedProvider === 'xai' + ? undefined + : (model.extendedContext ?? staticModel?.extendedContext), presetMapping: staticModel?.presetMapping, }; }); @@ -1344,5 +1344,6 @@ export function supportsExtendedContext( modelId: string, catalogOverride?: ProviderCatalog ): boolean { + if (provider.toLowerCase() === 'xai') return false; return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true; } diff --git a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts index 64a4062b..bb88a8c5 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { buildUiCatalog, MODEL_CATALOGS, supportsExtendedContext } from '@/lib/model-catalogs'; describe('xAI model catalog defaults', () => { it('mirrors the CLIProxyAPI text catalog and default tier routing', () => { @@ -27,4 +27,31 @@ describe('xAI model catalog defaults', () => { haiku: 'grok-composer-2.5-fast', }); }); + + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { + const catalog = MODEL_CATALOGS.xai; + + for (const model of catalog.models) { + expect(model.extendedContext).not.toBe(true); + expect(supportsExtendedContext('xai', model.id)).toBe(false); + } + }); + + it('strips generic extended-context metadata from live xAI catalogs', () => { + const catalog = buildUiCatalog('xai', { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-4.3', + models: [ + { + id: 'grok-4.3', + name: 'Grok 4.3', + extendedContext: true, + }, + ], + }); + + expect(catalog?.models[0]?.extendedContext).toBeUndefined(); + expect(supportsExtendedContext('xai', 'grok-4.3', catalog)).toBe(false); + }); }); From 44718e98ef2c3f9ec2cbe7434edb33cd9eecb390 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Jul 2026 19:04:37 -0400 Subject: [PATCH 34/35] fix(auth): preserve xAI profile compatibility --- src/api/services/index.ts | 8 +- src/api/services/profile-lifecycle-service.ts | 45 ++- src/api/services/profile-writer.ts | 32 +- src/api/services/validation-service.ts | 25 +- src/auth/account-context.ts | 8 +- src/auth/commands/create-command.ts | 17 +- src/auth/profile-detector.ts | 223 ++++++++++--- src/commands/api-command/create-command.ts | 30 +- src/commands/cliproxy/variant-subcommand.ts | 21 +- src/config/reserved-names.ts | 35 +- src/web-server/routes/profile-routes.ts | 8 +- tests/unit/account-context.test.ts | 5 +- .../api/profile-lifecycle-service.test.ts | 299 +++++++++++++++++- tests/unit/auth-resources-command.test.ts | 98 ++++++ tests/unit/auth/profile-detector.test.ts | 186 ++++++++++- .../api-create-grandfathered-force.test.ts | 113 +++++++ .../commands/cliproxy-variant-args.test.ts | 18 +- tests/unit/unified-config.test.ts | 8 + .../profile-routes-lifecycle.test.ts | 93 +++++- 19 files changed, 1166 insertions(+), 106 deletions(-) create mode 100644 tests/unit/commands/api-create-grandfathered-force.test.ts diff --git a/src/api/services/index.ts b/src/api/services/index.ts index 8b213e17..f4e00c66 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -5,7 +5,13 @@ */ // Validation services -export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service'; +export { + validateApiName, + validateApiNameSyntax, + validateUrl, + getUrlWarning, + sanitizeBaseUrl, +} from './validation-service'; // Profile types export { diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 39c6cf82..a9cf061d 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -9,14 +9,19 @@ import * as path from 'path'; import type { Config, Settings } from '../../types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; +import { ConfigError, ProfileError } from '../../errors/error-types'; import { getConfigPath } from '../../utils/config-manager'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { isSensitiveKey } from '../../utils/sensitive-keys'; -import { isReservedName } from '../../config/reserved-names'; +import { + canOverwriteGrandfatheredReservedProfileName, + isGrandfatheredReservedProfileName, + isReservedName, +} from '../../config/reserved-names'; -import { validateApiName } from './validation-service'; -import { listApiProfiles } from './profile-reader'; +import { validateApiName, validateApiNameSyntax } from './validation-service'; +import { apiProfileExists, listApiProfiles } from './profile-reader'; import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; import type { ApiProfileExportBundle, @@ -44,7 +49,7 @@ function parseTargetValue(value: unknown): TargetType | null { } function validateProfileNameForPath(name: string, label: string): string | null { - const validationError = validateApiName(name); + const validationError = validateApiNameSyntax(name); if (validationError) { return `Invalid ${label} profile name "${name}": ${validationError}`; } @@ -70,7 +75,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa if (isUnifiedMode()) { mutateConfig((config) => { if (config.profiles[name] && !force) { - throw new Error(`API profile already exists: ${name}`); + throw new ProfileError(`API profile already exists: ${name}`, name); } config.profiles[name] = { @@ -85,7 +90,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa const configPath = getConfigPath(); const config = loadConfigSafe() as Config; if (config.profiles[name] && !force) { - throw new Error(`API profile already exists: ${name}`); + throw new ProfileError(`API profile already exists: ${name}`, name); } config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`; @@ -124,7 +129,7 @@ function readJsonObject(filePath: string): Record { const raw = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Settings file must contain a JSON object.'); + throw new ConfigError('Settings file must contain a JSON object.', filePath); } return parsed as Record; } @@ -158,7 +163,10 @@ export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult { .filter((file) => !registeredSettings.has(file)) .filter((file) => !file.startsWith('base-')) .filter((file) => !ignoredNames.has(file)) - .filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length))) + .filter((file) => { + const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length); + return !isReservedName(name) || isGrandfatheredReservedProfileName(name); + }) .map((file) => { const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length); const settingsPath = path.join(ccsDir, file); @@ -206,7 +214,7 @@ export function registerApiProfileOrphans(options?: { const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] }; for (const orphan of selected) { - const nameError = validateApiName(orphan.name); + const nameError = validateApiNameSyntax(orphan.name); if (nameError) { result.skipped.push({ name: orphan.name, @@ -247,7 +255,16 @@ export function copyApiProfile( if (sourceError) return { success: false, error: sourceError }; const destinationError = validateApiName(destination); - if (destinationError) return { success: false, error: destinationError }; + const canOverwriteGrandfatheredDestination = canOverwriteGrandfatheredReservedProfileName( + destination, + { + force: options?.force === true, + exists: apiProfileExists(destination), + } + ); + if (destinationError && !canOverwriteGrandfatheredDestination) { + return { success: false, error: destinationError }; + } const sourceSettingsPath = getProfileSettingsPath(source); if (!fs.existsSync(sourceSettingsPath)) { @@ -363,7 +380,13 @@ export function importApiProfileBundle( const name = options?.name || input.profile.name; const nameError = validateApiName(name); - if (nameError) return { success: false, error: nameError }; + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force: options?.force === true, + exists: apiProfileExists(name), + }); + if (nameError && !canOverwriteGrandfatheredProfile) { + return { success: false, error: nameError }; + } const bundleTarget = parseTargetValue(input.profile.target); if (input.profile.target !== undefined && bundleTarget === null) { diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index dc366eae..2683d170 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -12,7 +12,10 @@ import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; -import { isReservedName } from '../../config/reserved-names'; +import { + canOverwriteGrandfatheredReservedProfileName, + isReservedName, +} from '../../config/reserved-names'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { extractProviderFromPathname, @@ -266,8 +269,18 @@ export function createApiProfile( models: ModelMapping, target: TargetType = 'claude', provider?: string, - extraModels?: string[] + extraModels?: string[], + options?: { force?: boolean } ): CreateApiProfileResult { + const nameError = validateApiName(name); + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force: options?.force === true, + exists: apiProfileExists(name), + }); + if (nameError && !canOverwriteGrandfatheredProfile) { + return { success: false, settingsFile: '', error: nameError }; + } + try { const deniedReason = getDeniedModelReason(baseUrl, models); if (deniedReason) { @@ -304,10 +317,17 @@ export function createCliproxyBridgeProfile( const providedName = options.name?.trim(); if (providedName) { const nameError = validateApiName(providedName); - if (nameError) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + providedName, + { + force: options.force === true, + exists: apiProfileExists(providedName), + } + ); + if (nameError && !canOverwriteGrandfatheredProfile) { return { success: false, settingsFile: '', error: nameError }; } - if (isReservedName(providedName)) { + if (isReservedName(providedName) && !canOverwriteGrandfatheredProfile) { return { success: false, settingsFile: '', @@ -332,7 +352,9 @@ export function createCliproxyBridgeProfile( resolved.apiKey, resolved.models, resolved.target, - provider + provider, + undefined, + { force: options.force } ); const detectedBridge = resolveCliproxyBridgeMetadata({ env: { diff --git a/src/api/services/validation-service.ts b/src/api/services/validation-service.ts index e9b830ce..1487a85c 100644 --- a/src/api/services/validation-service.ts +++ b/src/api/services/validation-service.ts @@ -8,10 +8,13 @@ import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; /** - * Validate API profile name + * Validate API profile name syntax for an existing profile reference. + * + * Existing profiles may use a name that became reserved after they were + * created, so this validator intentionally excludes built-in-name policy. * @returns Error message if invalid, null if valid */ -export function validateApiName(name: string): string | null { +export function validateApiNameSyntax(name: string): string | null { if (!name) { return 'API name is required'; } @@ -21,15 +24,27 @@ export function validateApiName(name: string): string | null { if (name.length > 32) { return 'API name must be 32 characters or less'; } - if (isReservedName(name)) { - return `'${name}' is a reserved name`; - } if (isWindowsReservedName(name)) { return `'${name}' is a Windows reserved device name and cannot be used`; } return null; } +/** + * Validate a name for creation of a new API profile. + * @returns Error message if invalid or reserved, null if valid + */ +export function validateApiName(name: string): string | null { + const syntaxError = validateApiNameSyntax(name); + if (syntaxError) { + return syntaxError; + } + if (isReservedName(name)) { + return `'${name}' is a reserved name`; + } + return null; +} + /** * Validate URL format * @returns Error message if invalid, null if valid diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index 52ce2eb8..6c030b4c 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -5,6 +5,8 @@ * project workspace context with other accounts in the same context group. */ +import { isReservedName } from '../config/reserved-names'; + export type AccountContextMode = 'isolated' | 'shared'; export type AccountContinuityMode = 'standard' | 'deeper'; @@ -36,7 +38,6 @@ export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default'; export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard'; export const MAX_CONTEXT_GROUP_LENGTH = 64; export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; -const RESERVED_ACCOUNT_PROFILE_NAMES = new Set(['default']); const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -58,10 +59,7 @@ export function isValidContextGroupName(value: string): boolean { * Validate account profile naming constraints. */ export function isValidAccountProfileName(value: string): boolean { - return ( - ACCOUNT_PROFILE_NAME_PATTERN.test(value) && - !RESERVED_ACCOUNT_PROFILE_NAMES.has(value.toLowerCase()) - ); + return ACCOUNT_PROFILE_NAME_PATTERN.test(value) && !isReservedName(value); } /** diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index ee78ffba..dd958b99 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -34,6 +34,7 @@ import { maybeShowPoolOnboardingHint, countNativeClaudeProfiles, } from '../../cliproxy/routing/pool-onboarding-hint'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; function sanitizeProfileNameForInstance(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); @@ -63,7 +64,19 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } - if (!isValidAccountProfileName(profileName)) { + // Check exact account ownership before applying the narrow grandfathered + // repair exception. API profiles and case variants do not qualify. + const existsLegacy = ctx.registry.hasProfile(profileName); + const existsUnified = ctx.registry.hasAccountUnified(profileName); + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + profileName, + { + force: force === true, + exists: existsLegacy || existsUnified, + } + ); + + if (!isValidAccountProfileName(profileName) && !canOverwriteGrandfatheredProfile) { exitWithError( 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.', ExitCode.PROFILE_ERROR @@ -71,8 +84,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise } // Check if profile already exists (check both legacy and unified) - const existsLegacy = ctx.registry.hasProfile(profileName); - const existsUnified = ctx.registry.hasAccountUnified(profileName); if (!force && (existsLegacy || existsUnified)) { // Keep the --force hint in the exitWithError message so it appears in the single output line exitWithError( diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index dbac96b3..e596a0cc 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -26,8 +26,11 @@ import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profi import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, + isCLIProxyProvider, resolveCLIProxyProviderShortcut, } from '../cliproxy/provider-capabilities'; +import { isGrandfatheredReservedProfileName } from '../config/reserved-names'; +import { ConfigError } from '../errors/error-types'; import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; @@ -85,6 +88,50 @@ export interface ProfileNotFoundError extends Error { availableProfiles: string; } +function hasOwnEntry(value: unknown, key: string): boolean { + return ( + typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, key) + ); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isResolvableCompositeVariant(value: unknown): value is CompositeVariantConfig { + if (!isObjectRecord(value) || value.type !== 'composite') { + return false; + } + if ( + value.default_tier !== 'opus' && + value.default_tier !== 'sonnet' && + value.default_tier !== 'haiku' + ) { + return false; + } + if (!isObjectRecord(value.tiers)) { + return false; + } + const tiers = value.tiers; + + return (['opus', 'sonnet', 'haiku'] as const).every((tierName) => { + const tier = tiers[tierName]; + return ( + isObjectRecord(tier) && + typeof tier.provider === 'string' && + isCLIProxyProvider(tier.provider) && + typeof tier.model === 'string' && + tier.model.trim().length > 0 + ); + }); +} + +function isResolvableAccountProfile(value: unknown): value is ProfileMetadata { + return ( + isObjectRecord(value) && typeof value.created === 'string' && value.created.trim().length > 0 + ); +} + /** * Profile Detector Class */ @@ -133,28 +180,22 @@ class ProfileDetector { config: UnifiedConfig ): ProfileDetectionResult | null { // Check CLIProxy variants first - if (config.cliproxy?.variants?.[profileName]) { - const variant = config.cliproxy.variants[profileName]; + if (hasOwnEntry(config.cliproxy?.variants, profileName)) { + const variant = config.cliproxy?.variants?.[profileName]; + if (!isObjectRecord(variant)) { + return null; + } // Handle composite variants if ('type' in variant && variant.type === 'composite') { - const composite = variant as CompositeVariantConfig; - - // Defensive: check for missing tiers or default_tier - if (!composite.tiers || !composite.default_tier) { + if (!isResolvableCompositeVariant(variant)) { console.warn( - `[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier` + `[!] Warning: Composite variant '${profileName}' has invalid or incomplete tiers/default_tier` ); return null; } - + const composite = variant; const defaultTierConfig = composite.tiers[composite.default_tier]; - if (!defaultTierConfig) { - console.warn( - `[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'` - ); - return null; - } return { type: 'cliproxy', @@ -170,6 +211,9 @@ class ProfileDetector { } const singleVariant = variant as CLIProxyVariantConfig; + if (!isCLIProxyProvider(singleVariant.provider)) { + return null; + } return { type: 'cliproxy', name: profileName, @@ -182,11 +226,18 @@ class ProfileDetector { // Check API profiles (supports compatibility aliases, e.g. km -> kimi) for (const candidate of getProfileLookupCandidates(profileName)) { - if (!config.profiles?.[candidate]) { + if (!hasOwnEntry(config.profiles, candidate)) { continue; } const profile = config.profiles[candidate]; + if ( + !isObjectRecord(profile) || + typeof profile.settings !== 'string' || + profile.settings.trim().length === 0 + ) { + return null; + } const settingsPath = profile.settings; const settingsEnv = loadSettingsFromFile(settingsPath); const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate); @@ -204,8 +255,11 @@ class ProfileDetector { } // Check accounts - if (config.accounts?.[profileName]) { + if (hasOwnEntry(config.accounts, profileName)) { const account = config.accounts[profileName]; + if (!isResolvableAccountProfile(account)) { + return null; + } return { type: 'account', name: profileName, @@ -225,6 +279,20 @@ class ProfileDetector { return null; } + private hasUnifiedConfiguredProfile(profileName: string, config: UnifiedConfig): boolean { + return ( + hasOwnEntry(config.cliproxy?.variants, profileName) || + hasOwnEntry(config.profiles, profileName) || + hasOwnEntry(config.accounts, profileName) + ); + } + + private createConfiguredCollisionError(profileName: string, source: string): ConfigError { + return new ConfigError( + `Configured profile "${profileName}" in ${source} is invalid and cannot be resolved as a grandfathered profile.` + ); + } + /** * Read settings-based config (config.json) */ @@ -264,11 +332,12 @@ class ProfileDetector { * * Priority order: * 0. Hardcoded special runtime profiles (copilot, cursor) - * 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...) + * 0.5. Hardcoded CLIProxy profiles (except grandfathered xai/grok collisions) * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] * 3. Settings-based profiles (config.profiles section) [legacy] * 4. Account-based profiles (profiles.json) [legacy] + * 5. Built-in xai/grok shortcut fallback when no configured profile exists */ detectProfileType(profileName: string | null | undefined): ProfileDetectionResult { // Special case: 'default' means use default profile @@ -336,7 +405,8 @@ class ProfileDetector { // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) const builtinProvider = resolveCLIProxyProviderShortcut(profileName); - if (builtinProvider) { + const shouldGrandfatherConfiguredProfile = builtinProvider === 'xai'; + if (builtinProvider && !shouldGrandfatherConfiguredProfile) { return { type: 'cliproxy', name: builtinProvider, @@ -349,6 +419,12 @@ class ProfileDetector { if (unifiedConfig) { const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig); if (result) return result; + if ( + isGrandfatheredReservedProfileName(profileName) && + this.hasUnifiedConfiguredProfile(profileName, unifiedConfig) + ) { + throw this.createConfiguredCollisionError(profileName, 'config.yaml'); + } // Fall through to legacy if not found in unified config } @@ -357,22 +433,36 @@ class ProfileDetector { const legacyTargetMap = (config as { profile_targets?: Record }) .profile_targets; - if (config.cliproxy && config.cliproxy[profileName]) { - const variant = config.cliproxy[profileName]; - return { - type: 'cliproxy', - name: profileName, - target: variant.target, - provider: variant.provider as CLIProxyProfileName, - settingsPath: variant.settings, - port: variant.port, - }; + if (hasOwnEntry(config.cliproxy, profileName)) { + const variant = config.cliproxy?.[profileName]; + if (!isObjectRecord(variant) || !isCLIProxyProvider(variant.provider as string)) { + if (isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'config.json'); + } + } else { + return { + type: 'cliproxy', + name: profileName, + target: variant.target, + provider: variant.provider, + settingsPath: variant.settings, + port: variant.port, + }; + } } // Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK if (config.profiles) { for (const candidate of getProfileLookupCandidates(profileName)) { - if (!config.profiles[candidate]) { + if (!hasOwnEntry(config.profiles, candidate)) { + continue; + } + + const settingsPath = config.profiles[candidate]; + if (typeof settingsPath !== 'string' || settingsPath.trim().length === 0) { + if (candidate === profileName && isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'config.json'); + } continue; } @@ -380,7 +470,7 @@ class ProfileDetector { return { type: 'settings', name: profileName, - settingsPath: config.profiles[candidate], + settingsPath, target: legacyTargetMap?.[candidate], message: viaLegacyAlias ? `Using legacy API profile "${candidate}" for "${profileName}".` @@ -392,11 +482,28 @@ class ProfileDetector { // Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK const profiles = this.readProfiles(); - if (profiles.profiles && profiles.profiles[profileName]) { + if (hasOwnEntry(profiles.profiles, profileName)) { + const profile = profiles.profiles[profileName]; + if (!isResolvableAccountProfile(profile)) { + if (isGrandfatheredReservedProfileName(profileName)) { + throw this.createConfiguredCollisionError(profileName, 'profiles.json'); + } + } else { + return { + type: 'account', + name: profileName, + profile, + }; + } + } + + // xai and grok became reserved when the built-in xAI provider shipped. Existing + // configured profiles keep working, while new installs still get the shortcuts. + if (builtinProvider) { return { - type: 'account', - name: profileName, - profile: profiles.profiles[profileName], + type: 'cliproxy', + name: builtinProvider, + provider: builtinProvider, }; } @@ -426,25 +533,49 @@ class ProfileDetector { if (unifiedConfig?.default) { const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig); if (result) return result; + if ( + isGrandfatheredReservedProfileName(unifiedConfig.default) && + this.hasUnifiedConfiguredProfile(unifiedConfig.default, unifiedConfig) + ) { + throw this.createConfiguredCollisionError(unifiedConfig.default, 'config.yaml'); + } } // Check if account-based default exists (legacy) const profiles = this.readProfiles(); - if (unifiedConfig?.default && profiles.profiles[unifiedConfig.default]) { - return { - type: 'account', - name: unifiedConfig.default, - profile: profiles.profiles[unifiedConfig.default], - }; + if (unifiedConfig?.default && hasOwnEntry(profiles.profiles, unifiedConfig.default)) { + const profile = profiles.profiles[unifiedConfig.default]; + if ( + isGrandfatheredReservedProfileName(unifiedConfig.default) && + !isResolvableAccountProfile(profile) + ) { + throw this.createConfiguredCollisionError(unifiedConfig.default, 'profiles.json'); + } + if (profile) { + return { + type: 'account', + name: unifiedConfig.default, + profile, + }; + } } - if (profiles.default && profiles.profiles[profiles.default]) { - return { - type: 'account', - name: profiles.default, - profile: profiles.profiles[profiles.default], - }; + if (profiles.default && hasOwnEntry(profiles.profiles, profiles.default)) { + const profile = profiles.profiles[profiles.default]; + if ( + isGrandfatheredReservedProfileName(profiles.default) && + !isResolvableAccountProfile(profile) + ) { + throw this.createConfiguredCollisionError(profiles.default, 'profiles.json'); + } + if (profile) { + return { + type: 'account', + name: profiles.default, + profile, + }; + } } // Check if settings-based default exists diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 77b585f3..1dc86cd7 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -22,6 +22,7 @@ import { isCLIProxyProvider, } from '../../cliproxy/provider-capabilities'; import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; @@ -63,7 +64,8 @@ function showPresetDeprecationNotice(presetId?: string): void { async function resolveProfileName( providedName: string | undefined, - preset: ProviderPreset | null + preset: ProviderPreset | null, + force: boolean ): Promise { const name = providedName || preset?.defaultProfileName; if (!name) { @@ -73,7 +75,11 @@ async function resolveProfileName( } const error = validateApiName(name); - if (error) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, { + force, + exists: apiProfileExists(name), + }); + if (error && !canOverwriteGrandfatheredProfile) { console.log(fail(error)); process.exit(1); } @@ -83,11 +89,19 @@ async function resolveProfileName( async function resolveCliproxyProfileName( provider: string, providedName: string | undefined, - yes: boolean | undefined + yes: boolean | undefined, + force: boolean ): Promise { if (providedName) { const error = validateApiName(providedName); - if (error) { + const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName( + providedName, + { + force, + exists: apiProfileExists(providedName), + } + ); + if (error && !canOverwriteGrandfatheredProfile) { console.log(fail(error)); process.exit(1); } @@ -368,7 +382,8 @@ export async function handleApiCreateCommand(args: string[]): Promise { const name = await resolveCliproxyProfileName( cliproxyProvider, parsedArgs.name, - parsedArgs.yes + parsedArgs.yes, + parsedArgs.force === true ); const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes); @@ -456,7 +471,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { showPresetDeprecationNotice(parsedArgs.preset); const preset = resolvePresetOrExit(parsedArgs.preset); - const name = await resolveProfileName(parsedArgs.name, preset); + const name = await resolveProfileName(parsedArgs.name, preset, parsedArgs.force === true); if (apiProfileExists(name) && !parsedArgs.force) { console.log(fail(`API '${name}' already exists`)); @@ -503,7 +518,8 @@ export async function handleApiCreateCommand(args: string[]): Promise { finalModels, target, undefined, - parsedArgs.extraModels + parsedArgs.extraModels, + { force: parsedArgs.force === true } ); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 2f9fdb11..c979691b 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -31,6 +31,7 @@ import { } from '../../cliproxy/services'; import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector'; import { CompositeTierConfig } from '../../config/unified-config-types'; +import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names'; import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity'; import { isUnifiedMode } from '../../config/config-loader-facade'; @@ -120,6 +121,24 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs { return result; } +export function validateVariantCreateName( + name: string, + force: boolean, + exists: boolean = variantExists(name) +): string | null { + const error = validateProfileName(name); + if ( + error && + !canOverwriteGrandfatheredReservedProfileName(name, { + force, + exists, + }) + ) { + return error; + } + return null; +} + function formatModelOption(model: ModelEntry): string { const tierBadge = model.tier === 'ultra' @@ -306,7 +325,7 @@ export async function handleCreate( validate: validateProfileName, }); } else { - const error = validateProfileName(name); + const error = validateVariantCreateName(name, parsedArgs.force === true); if (error) { console.log(fail(error)); process.exit(1); diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index dacfb97d..42e7b6d5 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -1,3 +1,5 @@ +import { ConfigError } from '../errors/error-types'; + /** * Reserved profile names that cannot be used for user-defined profiles. * These names are reserved for CLIProxy providers and CLI commands. @@ -6,6 +8,8 @@ export const RESERVED_PROFILE_NAMES = [ // CLIProxy providers (built-in OAuth) 'gemini', 'codex', + 'xai', + 'grok', 'agy', 'qwen', 'iflow', @@ -32,6 +36,14 @@ export const RESERVED_PROFILE_NAMES = [ export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; +/** + * Reserved names that may still identify profiles created before the + * corresponding built-in provider shortcuts shipped. + */ +export const GRANDFATHERED_RESERVED_PROFILE_NAMES = ['xai', 'grok'] as const; +export type GrandfatheredReservedProfileName = + (typeof GRANDFATHERED_RESERVED_PROFILE_NAMES)[number]; + /** * Windows reserved device names - cannot be used as filenames on Windows. * Case-insensitive on Windows filesystem. @@ -70,6 +82,27 @@ export function isReservedName(name: string): boolean { return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName); } +/** + * Check whether a reserved name may identify an existing grandfathered profile. + * This does not permit creating a new profile with the name. + */ +export function isGrandfatheredReservedProfileName(name: string): boolean { + return GRANDFATHERED_RESERVED_PROFILE_NAMES.includes( + name.toLowerCase() as GrandfatheredReservedProfileName + ); +} + +/** + * Allow a reserved grandfathered name only for an explicit overwrite of the + * exact profile that already owns it. + */ +export function canOverwriteGrandfatheredReservedProfileName( + name: string, + options: { force: boolean; exists: boolean } +): boolean { + return options.force && options.exists && isGrandfatheredReservedProfileName(name); +} + /** * Check if a name is a Windows reserved device name. * These cause filesystem errors on Windows systems. @@ -89,7 +122,7 @@ export function isWindowsReservedName(name: string): boolean { */ export function validateProfileName(name: string): void { if (isReservedName(name)) { - throw new Error( + throw new ConfigError( `Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}` ); } diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index a5ea70ac..1b0b940a 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -22,7 +22,7 @@ import { apiProfileExists, listCliproxyBridgeProviders, listApiProfiles, - validateApiName, + validateApiNameSyntax, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; import { getPersistedTargetChoices } from '../../targets/target-metadata'; @@ -284,7 +284,7 @@ router.post('/orphans/register', (req: Request, res: Response): void => { } names = payload.names.map((value) => value.trim()); - const invalidName = names.find((name) => validateApiName(name) !== null); + const invalidName = names.find((name) => validateApiNameSyntax(name) !== null); if (invalidName) { res.status(400).json({ error: `Invalid profile name in names: ${invalidName}` }); return; @@ -321,7 +321,7 @@ router.post('/:name/copy', (req: Request, res: Response): void => { } const { name } = req.params; - const sourceNameError = validateApiName(name); + const sourceNameError = validateApiNameSyntax(name); if (sourceNameError) { res.status(400).json({ error: sourceNameError }); return; @@ -359,7 +359,7 @@ router.post('/:name/export', (req: Request, res: Response): void => { } const { name } = req.params; - const profileNameError = validateApiName(name); + const profileNameError = validateApiNameSyntax(name); if (profileNameError) { res.status(400).json({ error: profileNameError }); return; diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index affb01b3..89a4854d 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -21,8 +21,9 @@ describe('account context helpers', () => { }); it('rejects reserved account profile names', () => { - expect(isValidAccountProfileName('default')).toBe(false); - expect(isValidAccountProfileName('Default')).toBe(false); + for (const name of ['default', 'Default', 'xai', 'XAI', 'grok', 'GROK']) { + expect(isValidAccountProfileName(name)).toBe(false); + } }); it('falls back to default shared group for invalid persisted metadata', () => { diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 310d9af5..5d984973 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,6 +9,7 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { createApiProfile } from '../../../src/api/services/profile-writer'; import { loadConfigSafe, runWithScopedConfigDir, @@ -68,7 +69,7 @@ describe('profile lifecycle service', () => { } }); - it('discovers only API profile orphans (skips registered and reserved names)', async () => { + it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -101,9 +102,59 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); + for (const profileName of ['xai', 'grok']) { + fs.writeFileSync( + path.join(ccsDir, `${profileName}.settings.json`), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: 'token', + }, + }, + null, + 2 + ) + '\n' + ); + } const result = await runInScopedCcsDir(() => discoverApiProfileOrphans()); - expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']); + expect(result.orphans.map((orphan) => orphan.name).sort()).toEqual(['extra', 'grok', 'xai']); + }); + + it('registers a grandfathered xai orphan without opening general reserved-name creation', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'gemini.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + + const result = await runInScopedCcsDir(() => + registerApiProfileOrphans({ names: ['xai', 'gemini'] }) + ); + const config = await runInScopedCcsDir(() => loadConfigSafe()); + + expect(result.registered).toEqual(['xai']); + expect(result.skipped).toEqual([]); + expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json'); + expect(config.profiles.gemini).toBeUndefined(); }); it('treats explicit empty names list as no-op during orphan registration', async () => { @@ -140,7 +191,10 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => { throw new Error('copy failed'); @@ -168,8 +222,15 @@ describe('profile lifecycle service', () => { 2 ) + '\n' ); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); - fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + 'version: 12\nwebsearch:\n enabled: false\n', + 'utf8' + ); const originalCopyFileSync = fs.copyFileSync.bind(fs); const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => { @@ -195,7 +256,10 @@ describe('profile lifecycle service', () => { const malformedPath = path.join(ccsDir, 'bad.settings.json'); fs.writeFileSync(malformedPath, '{ invalid json', 'utf8'); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: ['bad'], force: true }) @@ -247,6 +311,173 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid source profile name'); }); + it('copies and exports grandfathered sources but rejects reserved destinations', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const exported = await runInScopedCcsDir(() => exportApiProfile('xai')); + const copied = await runInScopedCcsDir(() => copyApiProfile('xai', 'xai-backup')); + const rejectedDestination = await runInScopedCcsDir(() => copyApiProfile('xai', 'GROK')); + + expect(exported.success).toBe(true); + expect(exported.bundle?.profile.name).toBe('xai'); + expect(copied.success).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'xai-backup.settings.json'))).toBe(true); + expect(rejectedDestination.success).toBe(false); + expect(rejectedDestination.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'GROK.settings.json'))).toBe(false); + }); + + it('allows force copy only onto an exact existing grandfathered destination', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { + source: '~/.ccs/source.settings.json', + xai: '~/.ccs/xai.settings.json', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'source.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://source.example.com', + ANTHROPIC_AUTH_TOKEN: 'source-token', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const nonForced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai')); + const forced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai', { force: true })); + const absentForced = await runInScopedCcsDir(() => + copyApiProfile('source', 'grok', { force: true }) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + expect(absentForced.success).toBe(false); + expect(absentForced.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + + const copiedSettings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(copiedSettings.env.ANTHROPIC_BASE_URL).toBe('https://source.example.com'); + expect(copiedSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('source-token'); + }); + + it('rejects an absent reserved name even when profile creation is forced', async () => { + const result = await runInScopedCcsDir(() => + createApiProfile( + 'XAI', + 'https://api.example.com', + 'token', + { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }, + 'claude', + undefined, + undefined, + { force: true } + ) + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('reserved name'); + expect(fs.existsSync(path.join(getScopedCcsDir(), 'XAI.settings.json'))).toBe(false); + }); + + it('allows force to repair an exact existing xai API profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const nonForced = await runInScopedCcsDir(() => + createApiProfile('xai', 'https://new.example.com', 'new-token', { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }) + ); + const forced = await runInScopedCcsDir(() => + createApiProfile( + 'xai', + 'https://new.example.com', + 'new-token', + { + default: 'model', + opus: 'model', + sonnet: 'model', + haiku: 'model', + }, + 'claude', + undefined, + undefined, + { force: true } + ) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token'); + }); + it('rolls back copied settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -294,6 +525,62 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid bundle profile target'); }); + it('allows force import only for an exact existing grandfathered profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + + const bundle = { + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { name: 'xai', target: 'claude' }, + settings: { + env: { + ANTHROPIC_BASE_URL: 'https://repaired.example.com', + ANTHROPIC_AUTH_TOKEN: 'repaired-token', + }, + }, + }; + + const nonForced = await runInScopedCcsDir(() => importApiProfileBundle(bundle)); + const forced = await runInScopedCcsDir(() => importApiProfileBundle(bundle, { force: true })); + const absentForced = await runInScopedCcsDir(() => + importApiProfileBundle( + { + ...bundle, + profile: { ...bundle.profile, name: 'grok' }, + }, + { force: true } + ) + ); + + expect(nonForced.success).toBe(false); + expect(nonForced.error).toContain('reserved name'); + expect(forced.success).toBe(true); + expect(absentForced.success).toBe(false); + expect(absentForced.error).toContain('reserved name'); + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://repaired.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('repaired-token'); + }); + it('rolls back imported settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/auth-resources-command.test.ts b/tests/unit/auth-resources-command.test.ts index 2264ed3d..a81b8965 100644 --- a/tests/unit/auth-resources-command.test.ts +++ b/tests/unit/auth-resources-command.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as childProcess from 'child_process'; import ProfileRegistry from '../../src/auth/profile-registry'; import InstanceManager from '../../src/management/instance-manager'; import { handleResources } from '../../src/auth/commands/resources-command'; @@ -227,6 +228,103 @@ describe('auth resources command', () => { expect(output).toContain('Unknown option(s): "--mode"'); }); + it('rejects reserved xai and grok account profile names on auth create', async () => { + for (const profileName of ['xai', 'GROK']) { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + + const output = await expectProfileExit(() => + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + [profileName] + ) + ); + + expect(output).toContain('Invalid profile name'); + expect(registry.hasAccountUnified(profileName)).toBe(false); + } + }); + + it('rejects --force for an absent reserved account profile', async () => { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + + const output = await expectProfileExit(() => + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['xai', '--force'] + ) + ); + + expect(output).toContain('Invalid profile name'); + expect(registry.hasAccountUnified('xai')).toBe(false); + }); + + it('allows --force to repair an exact existing xai account profile', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' xai:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const claudeDetector = await import('../../src/utils/claude-detector'); + const cliSpy = spyOn(claudeDetector, 'getClaudeCliInfo').mockReturnValue({ + path: '/usr/bin/claude', + isWindows: false, + needsShell: false, + }); + const child = { + on: () => child, + } as unknown as ReturnType; + const spawnSpy = spyOn(childProcess, 'spawn').mockReturnValue(child); + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + path.join(ccsDir, 'instances', 'xai') + ); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + await handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['xai', '--force'] + ); + + expect(spawnSpy).toHaveBeenCalled(); + expect(registry.hasAccountUnified('xai')).toBe(true); + } finally { + logSpy.mockRestore(); + ensureSpy.mockRestore(); + spawnSpy.mockRestore(); + cliSpy.mockRestore(); + } + }); + it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => { const registry = new ProfileRegistry(); const instanceMgr = new InstanceManager(); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index c028453a..66b4f0da 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import * as os from 'os'; import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector'; import * as unifiedConfigLoader from '../../../src/config/unified-config-loader'; +import { ConfigError } from '../../../src/errors/error-types'; describe('ProfileDetector', () => { const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`); @@ -101,11 +102,186 @@ describe('ProfileDetector', () => { expect(detector.detectProfileType('qoder').provider).toBe('qoder'); }); - it('should resolve the grok CLI alias to canonical xai routing', () => { - const result = detector.detectProfileType('grok'); - expect(result.type).toBe('cliproxy'); - expect(result.name).toBe('xai'); - expect(result.provider).toBe('xai'); + it('should resolve xai and grok to built-in xai routing when no configured profile exists', () => { + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false); + + try { + for (const shortcut of ['xai', 'grok']) { + const result = detector.detectProfileType(shortcut); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('xai'); + expect(result.provider).toBe('xai'); + } + } finally { + isUnifiedModeSpy.mockRestore(); + existsSyncSpy.mockRestore(); + } + }); + + it('should preserve configured xai and grok profiles from unified config', () => { + const xaiSettingsPath = path.join(tempDir, 'xai.settings.json'); + const grokSettingsPath = path.join(tempDir, 'grok.settings.json'); + fs.writeFileSync( + xaiSettingsPath, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-xai.example' } }) + ); + fs.writeFileSync( + grokSettingsPath, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-grok.example' } }) + ); + + const mockUnifiedConfig = { + version: 2, + profiles: { + xai: { settings: xaiSettingsPath, type: 'api' }, + grok: { settings: grokSettingsPath, type: 'api' }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + for (const profileName of ['xai', 'grok']) { + const result = detector.detectProfileType(profileName); + expect(result.type).toBe('settings'); + expect(result.name).toBe(profileName); + expect(result.settingsPath).toBe( + profileName === 'xai' ? xaiSettingsPath : grokSettingsPath + ); + } + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed unified xai composite instead of using the built-in shortcut', () => { + const mockUnifiedConfig = { + version: 12, + profiles: {}, + accounts: {}, + cliproxy: { + variants: { + xai: { + type: 'composite', + default_tier: 'opus', + tiers: {}, + }, + }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(() => detector.detectProfileType('xai')).toThrow(ConfigError); + expect(() => detector.detectProfileType('xai')).toThrow( + /Configured profile "xai" in config.yaml is invalid/ + ); + } finally { + warningSpy.mockRestore(); + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed legacy grok variant instead of using the built-in shortcut', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {}, cliproxy: { grok: {} } }, null, 2) + ); + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + + try { + const localDetector = new ProfileDetector(); + expect(() => localDetector.detectProfileType('grok')).toThrow(ConfigError); + expect(() => localDetector.detectProfileType('grok')).toThrow( + /Configured profile "grok" in config.json is invalid/ + ); + } finally { + isUnifiedModeSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + + it('should reject a malformed unified xai default instead of using the native default', () => { + const mockUnifiedConfig = { + version: 12, + default: 'xai', + profiles: {}, + accounts: {}, + cliproxy: { + variants: { + xai: { + type: 'composite', + default_tier: 'opus', + tiers: {}, + }, + }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + const warningSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + try { + expect(() => detector.resolveDefaultProfileResult()).toThrow(ConfigError); + expect(() => detector.resolveDefaultProfileResult()).toThrow( + /Configured profile "xai" in config.yaml is invalid/ + ); + } finally { + warningSpy.mockRestore(); + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should reject a malformed legacy xai account default', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify({ default: 'xai', profiles: { xai: {} } }, null, 2) + ); + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + + try { + const localDetector = new ProfileDetector(); + expect(() => localDetector.resolveDefaultProfileResult()).toThrow(ConfigError); + expect(() => localDetector.resolveDefaultProfileResult()).toThrow( + /Configured profile "xai" in profiles.json is invalid/ + ); + } finally { + isUnifiedModeSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } }); it('should detect settings-based profile from unified config', () => { diff --git a/tests/unit/commands/api-create-grandfathered-force.test.ts b/tests/unit/commands/api-create-grandfathered-force.test.ts new file mode 100644 index 00000000..8795562f --- /dev/null +++ b/tests/unit/commands/api-create-grandfathered-force.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { handleApiCreateCommand } from '../../../src/commands/api-command/create-command'; + +describe('api create grandfathered force policy', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnifiedMode: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-create-grandfathered-')); + originalCcsHome = process.env.CCS_HOME; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_UNIFIED_CONFIG; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + if (originalUnifiedMode === undefined) { + delete process.env.CCS_UNIFIED_CONFIG; + } else { + process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + } + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('allows ccs api create --force to repair an exact existing xai profile', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } }, + null, + 2 + ) + '\n' + ); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleApiCreateCommand([ + 'xai', + '--force', + '--yes', + '--base-url', + 'https://new.example.com', + '--api-key', + 'new-token', + '--model', + 'new-model', + ]); + } finally { + logSpy.mockRestore(); + } + + const settings = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8') + ) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token'); + }); + + it('rejects ccs api create --force when the reserved profile is absent', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + + const originalExit = process.exit; + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + try { + await expect( + handleApiCreateCommand([ + 'grok', + '--force', + '--yes', + '--base-url', + 'https://new.example.com', + '--api-key', + 'new-token', + '--model', + 'new-model', + ]) + ).rejects.toThrow('process.exit(1)'); + } finally { + process.exit = originalExit; + logSpy.mockRestore(); + } + + expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false); + }); +}); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts index 5e3f514c..54b5aa75 100644 --- a/tests/unit/commands/cliproxy-variant-args.test.ts +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand'; +import { + parseProfileArgs, + validateVariantCreateName, +} from '../../../src/commands/cliproxy/variant-subcommand'; describe('cliproxy variant arg parser', () => { test('parses --target value form', () => { @@ -56,3 +59,16 @@ describe('cliproxy variant arg parser', () => { expect(parsed.errors).toEqual([]); }); }); + +describe('cliproxy variant create name policy', () => { + test('allows force repair of an exact existing grandfathered variant', () => { + expect(validateVariantCreateName('xai', true, true)).toBeNull(); + expect(validateVariantCreateName('GROK', true, true)).toBeNull(); + }); + + test('keeps absent, non-force, and other reserved variant names blocked', () => { + expect(validateVariantCreateName('xai', true, false)).toContain('reserved name'); + expect(validateVariantCreateName('xai', false, true)).toContain('reserved name'); + expect(validateVariantCreateName('gemini', true, true)).toContain('reserved name'); + }); +}); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index b8566a28..095195bb 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -44,6 +44,8 @@ describe('reserved-names', () => { expect(RESERVED_PROFILE_NAMES).toContain('agy'); expect(RESERVED_PROFILE_NAMES).toContain('qwen'); expect(RESERVED_PROFILE_NAMES).toContain('iflow'); + expect(RESERVED_PROFILE_NAMES).toContain('xai'); + expect(RESERVED_PROFILE_NAMES).toContain('grok'); }); it('should include CLI commands', () => { @@ -57,12 +59,16 @@ describe('reserved-names', () => { it('should return true for reserved names', () => { expect(isReservedName('gemini')).toBe(true); expect(isReservedName('codex')).toBe(true); + expect(isReservedName('xai')).toBe(true); + expect(isReservedName('grok')).toBe(true); expect(isReservedName('default')).toBe(true); }); it('should be case-insensitive', () => { expect(isReservedName('GEMINI')).toBe(true); expect(isReservedName('Codex')).toBe(true); + expect(isReservedName('XAI')).toBe(true); + expect(isReservedName('GROK')).toBe(true); expect(isReservedName('DEFAULT')).toBe(true); }); @@ -76,6 +82,8 @@ describe('reserved-names', () => { describe('validateProfileName', () => { it('should throw for reserved names', () => { expect(() => validateProfileName('gemini')).toThrow(/reserved/i); + expect(() => validateProfileName('xai')).toThrow(/reserved/i); + expect(() => validateProfileName('grok')).toThrow(/reserved/i); expect(() => validateProfileName('default')).toThrow(/reserved/i); }); diff --git a/tests/unit/web-server/profile-routes-lifecycle.test.ts b/tests/unit/web-server/profile-routes-lifecycle.test.ts index a79ae8e1..d215cf71 100644 --- a/tests/unit/web-server/profile-routes-lifecycle.test.ts +++ b/tests/unit/web-server/profile-routes-lifecycle.test.ts @@ -76,11 +76,17 @@ describe('profile-routes lifecycle endpoints', () => { it('does not register all orphans when names=[] is explicitly passed', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); fs.writeFileSync( path.join(ccsDir, 'lonely.settings.json'), - JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + - '\n' + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' ); const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, { @@ -142,4 +148,85 @@ describe('profile-routes lifecycle endpoints', () => { const body = (await response.json()) as { error: string }; expect(body.error).toContain('API name must start with letter'); }); + + it('keeps grandfathered profiles exportable, copyable, and removable', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const exportResponse = await fetch(`${baseUrl}/api/profiles/xai/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(exportResponse.status).toBe(200); + + const copyResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination: 'xai-backup' }), + }); + expect(copyResponse.status).toBe(201); + + const reservedDestinationResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination: 'GROK' }), + }); + expect(reservedDestinationResponse.status).toBe(400); + const reservedDestinationBody = (await reservedDestinationResponse.json()) as { + error: string; + }; + expect(reservedDestinationBody.error).toContain('reserved name'); + + const deleteResponse = await fetch(`${baseUrl}/api/profiles/xai`, { + method: 'DELETE', + }); + expect(deleteResponse.status).toBe(200); + expect(fs.existsSync(path.join(ccsDir, 'xai.settings.json'))).toBe(false); + }); + + it('recovers a grandfathered xai orphan through the route', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: {} }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'xai.settings.json'), + JSON.stringify( + { env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, + null, + 2 + ) + '\n' + ); + + const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ names: ['xai'] }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { registered: string[]; skipped: unknown[] }; + expect(body.registered).toEqual(['xai']); + expect(body.skipped).toEqual([]); + + const config = JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8')) as { + profiles: Record; + }; + expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json'); + }); }); From 78a73355297aa18f2d941be7c363c361199e6acf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Jul 2026 23:11:51 +0000 Subject: [PATCH 35/35] chore(release): 8.7.0-dev.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a74e8f23..41322e0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.7.0-dev.11", + "version": "8.7.0-dev.12", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",